MERN-技术栈高级教程-七-

47 阅读27分钟

MERN 技术栈高级教程(七)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

十二、服务器渲染

在这一章中,我们将探索 React 的另一个基石,除了能够直接渲染到 DOM 之外,还能够在服务器上生成 HTML。这使得能够创建同构的应用,也就是说,应用在服务器和客户机上使用相同的代码库来完成任务:渲染到 DOM 或创建 HTML。

服务器呈现(也称为服务器端呈现或简称为 SSR)与 SPA 的特征相反:不是通过 API 获取数据并在浏览器上构建 DOM,而是在服务器上构建整个 HTML 并发送到浏览器。当应用需要被搜索引擎索引时,就需要它了。搜索引擎机器人通常从根 URL ( /)开始,然后遍历结果 HTML 中存在的所有超链接。它们不执行 JavaScript 来通过 Ajax 调用获取数据,也不查看更改后的 DOM。因此,要让应用的页面被搜索引擎正确地索引,服务器需要响应与 Ajax 调用componentDidMount()方法和随后重新呈现页面后相同的 HTML。

例如,如果向/issues发出请求,那么返回的 HTML 应该预先填充了表中的问题列表。同样的道理也适用于所有其他可以加入书签或者有超链接指向它们的页面。但是这违背了 SPAs 的目的,当用户在应用中导航时,SPAs 提供了更好的用户体验。因此,我们的工作方式如下:

  • 第一次在应用中打开任何页面时(例如,通过在浏览器中键入 URL,或者甚至在已经在应用的任何页面中时在浏览器上选择刷新),整个页面将被构造并从服务器返回。我们称之为服务器渲染

  • 一旦任何页面被加载并且用户导航到另一个页面,我们将使它像 SPA 一样工作。也就是只做 API,直接在浏览器上修改 DOM。我们称之为浏览器渲染

请注意,这适用于任何页面,而不仅仅是主页。例如,用户可以在浏览器中键入某期杂志编辑页面的 URL,该页面将在服务器上构建并返回。

由于实现这一切的步骤并不简单,我们将创建一个新的简单页面——关于页面——用于掌握所需的技术。我们将从一个静态页面开始,然后通过使用 API 获取它所呈现的数据来增加复杂性。一旦我们完善了在服务器上呈现 About 页面的技术,我们将把所需的更改扩展到所有其他页面。

注意:不是所有的应用都需要服务器渲染。如果一个应用不需要被搜索引擎索引,就可以避免服务器渲染带来的复杂性。通常,如果页面没有公共访问权限,它们不需要搜索引擎索引。单单性能优势不足以证明服务器渲染的复杂性。

新目录结构

到目前为止,我们并没有过多关注 UI 的目录结构。这是因为src目录下的所有代码都应该被编译成一个包并提供给浏览器。这种情况不会再发生了。我们需要的是三组文件:

  • 所有的共享文件,本质上就是所有的 React 组件。

  • 用于使用 Express 运行 UI 服务器的一组文件。这将为服务器渲染导入共享的 React 组件。

  • 浏览器包的起点,包含所有共享的 React 组件,可以发送到浏览器执行。

所以,让我们把当前的源代码分成三个目录,每个目录对应一组文件。让我们将共享的 React 组件保存在src目录中。至于特定于浏览器和特定于服务器的文件,让我们分别创建两个名为browserserver的目录。然后,让我们将特定于浏览器的文件App.jsx移到它的目录中,将特定于服务器的文件uiserver.js移到server目录中。

$ cd ui
$ mkdir browser
$ mkdir server
$ mv src/App.jsx browser
$ mv uiserver.js server

这种变化还要求林挺、编译和捆绑配置发生变化,以反映新的目录结构。让我们从林挺的变化开始。让我们有四个.eslintrc文件,一个在根目录(ui),一个在子目录中——srcbrowserserver目录——这样每个文件都继承了根目录下的文件。在ui目录的.eslintrc处,我们需要做的就是指定要继承的规则集,也就是airbnb。对此的更改如清单 12-1 所示。

...
{
  "extends": "airbnb-base",
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}
...

Listing 12-1ui/.eslintrc: Changes to Keep Only the Base

接下来,在共享的src文件夹中,让我们为环境添加node: true,以表明这些文件集将在 Node.js 和浏览器中运行。我们还将删除extends规范,因为它将从父目录的.eslintrc继承而来。对此的更改如清单 12-2 所示。

...
{
  "extends": "airbnb",
  "env": {
    "browser": true,
    "node": true
   },
   rules: {
  ...
}
...

Listing 12-2ui/src/.eslintrc: Changes to Add Node

现在,让我们在browser目录下创建一个新的.eslintrc,它与src目录下原来的.eslintrc相同,没有 Node.js 环境。这个新文件如清单 12-3 所示。

{
  "env": {
    "browser": true
  },
  rules: {
    "import/extensions": [ 'error', 'always', { ignorePackages: true } ],
    "react/prop-types": "off"
  }
}

Listing 12-3ui/browser/.eslintrc: New ESLint Configuration for Browser Source Files

至于服务器.eslintrc,它是原始服务器在ui目录中的副本,只指定环境(仅 Node.js)并允许控制台消息。如清单 12-4 所示。

{
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}

Listing 12-4ui/server/.eslintrc: New ESLint Configuration for Server Source Files

下一步,让我们在browser目录中添加一个.babelrc,它是共享的src目录中的一个副本。

$ cd ui
$ cp src/.babelrc browser

我们还需要在App.jsxuiserver.js中更改导入/必需文件的位置。这些显示在清单 12-5 和 12-6 中。

...
import Page from '../src/Page.jsx';
...

Listing 12-5ui/browser/App.jsx: New Location of Page.jsx

...

const config = require('./webpack.config.js');

const config = require('../webpack.config.js');

...

Listing 12-6ui/server/uiserver.js: New Location of Webpack Config File

最后,我们需要在package.json中为 UI 服务器启动文件的新位置更改入口点,在webpack.config.js中为App.jsx的位置更改入口点。这些显示在清单 12-7 和 12-8 中。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "start": "nodemon -w server -w .env server/uiserver.js",
    ...
  }
...

Listing 12-7ui/package.json: Changes for Entry Point of uiserver.js

...
module.exports = {
  ...
  entry: { app: ['./src/App.jsx'] },
  entry: { app: ['./browser/App.jsx'] },
  ...
}

Listing 12-8ui/webpack.config.js

: Changes for Entry Point of the Bundle

经过这些更改后,应用应该像以前一样工作。您应该在启用 HMR 和禁用 HMR 的情况下进行测试,并在启动服务器之前使用npm run compile手动编译浏览器包。

基本服务器渲染

我们使用ReactDOM.render()方法将一个 React 元素呈现到 DOM 中。用于在服务器端创建 HTML 的对应方法是ReactDOMServer.renderToString()。虽然这个方法本身很简单,但是为了使用它我们需要做的改变却不简单。所以,让我们用一个简单的About组件来熟悉一下基本原理。然后,在后面的部分中,我们将对应用中的其他组件使用相同的技术。

清单 12-9 中显示了非常基本的About组件的代码。

import React from 'react';

export default function About() {
  return (
    <div className="text-center">
      <h3>Issue Tracker version 0.9</h3>
      <h4>
        API version 1.0
      </h4>
    </div>
  );
}

Listing 12-9ui/src/About.jsx: New About Component

让我们在应用中包含新的组件,以便在单击扩展菜单项 About 时显示它。这需要的第一个改变是在路由组中,因此/about加载了About组件。清单 12-10 中显示了对Contents.jsx的更改。

...
import IssueEdit from './IssueEdit.jsx';

import About from './About.jsx';

...
    <Switch>
      ...
      <Route path="/report" component={IssueReport} />
      <Route path="/about" component={About} />
      ...
    </Switch>
...

Listing 12-10ui/src/Contents.jsx: Include About Component in the Application

我们还需要更改菜单项,使其链接到/about,而不是虚拟的。清单 12-11 中显示了对Page.jsx的更改。

...
function NavBar() {
  return (
    ...
          <LinkContainer to="/about">
            <MenuItem>About</MenuItem>
          </LinkContainer>
    ...
  );
}
...

Listing 12-11ui/src/Page.jsx: Include About in the Navigation Bar

现在,如果您将浏览器指向http://localhost:8000/并通过点击 About extended 菜单项导航到新页面,您应该会看到 About 页面,就像应用中的任何其他页面一样。该页面截图如图 12-1 所示。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig1_HTML.jpg

图 12-1

“关于”页面

但是在这种情况下,组件是由浏览器呈现的,就像所有其他组件到目前为止被呈现的一样。图 12-2 显示了导致使用浏览器渲染显示“关于”页面的事件序列图。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig2_HTML.jpg

图 12-2

浏览器渲染序列图

下面是对浏览器渲染过程中发生的事情的解释。解释和图表用于“关于”页面,但顺序对于任何其他页面都是相同的:

  1. 用户在主页上键入 URL 或刷新浏览器。

  2. UI 服务器返回index.html,它引用了 JavaScript app.bundle.js。它也被获取,并且包含 react 组件,包括About组件。现在,页面被认为是加载的。(问题列表组件也将被挂载,但目前这并不重要。)

  3. 用户单击“关于”页面的链接。

  4. React 挂载并呈现About组件,其代码是 JavaScript 包的一部分。此时,组件中的所有静态文本都可以看到。

  5. 一旦初始挂载完成,就会调用componentDidMount(),这将触发对 API 服务器的调用,以获取组件的数据。我们还没有实现这一点,但是通过考虑我们已经实现的其他页面,例如问题列表页面,您应该能够理解这一点。

  6. 使用 API 调用成功获取数据后,组件将使用数据重新呈现。

接下来,让我们在服务器上呈现About组件。由于服务器尚未使用 JSX 编译,我们需要手动将其编译为纯 JavaScript,以便服务器可以包含它:

$ cd ui
$ npx babel src/About.jsx --out-dir server

这将在server目录中产生一个名为About.js的文件。现在,在服务器上,导入About.js后,我们可以使用下面的代码片段呈现组件的字符串表示:

...
  const markup = ReactDOMServer.renderToString(<About />);
...

但是这将只为About组件产生标记。我们仍然需要 HTML 的其余部分,如<head>部分,以及插入到contents <div>中的组件。所以,让我们用现有的index.html制作一个模板,它可以接受<div>的内容并返回完整的 HTML。

pug 等强大的模板语言可以用于此,但我们的要求非常简单,因此我们将只使用 ES2015 模板字符串功能。让我们将这个函数放在server目录下一个名为template.js的文件中。模板字符串与index.html的内容相同,只是删除了<script>标签。该文件的全部内容如清单 12-12 所示。突出显示的变化是与index.html的不同。

function template(body) {

  return `<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" >
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <style>
    table.table-hover tr {cursor: pointer;}
    .panel-title a {display: block; width: 100%; cursor: pointer;}
  </style>
</head>

<body>
  <!-- Page generated from template. -->
  <div id="contents">${body}</div>

  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>

</html>

`;

}

module.exports = template;

Listing 12-12ui/server/template.js: Template for Server Rendered HTML

我们最终会添加脚本,但目前,最好在没有这种复杂性的情况下测试更改。至于导入About组件并将其呈现为 string,让我们在服务器目录的一个新文件中,在一个名为render()的函数中完成这项工作。该函数将像任何快速路由处理程序一样接收常规请求和响应。然后,它将发送模板作为响应,用从ReactDOMServer.renderToString()创建的标记替换主体。

由于server目录中的代码仍然是纯 JavaScript(还没有编译步骤),所以我们不使用 JSX 来实例化About组件。相反,让我们用React.createElement:

...
  const body = ReactDOMServer.renderToString(
    React.createElement(About),
  );
...

srcserver目录中的代码之间还有一个小的不兼容性。这些使用不同的方式来包含其他文件和模块。您可能还记得第 8 “模块化和 Webpack”,React 代码使用了import/export范式,而不是像在服务器代码中那样使用require/module.exports方式包含模块。幸运的是,这两者是兼容的,有一个小的变化。Babel 编译器将使用export default关键字导出的任何变量也放在module.exports中,但是使用属性default。因此,我们必须在使用require()导入About组件后添加default:

...
const About = require('./About.js').default;
...

这个新文件的完整代码如清单 12-13 所示。

const React = require('react');
const ReactDOMServer = require('react-dom/server');

const About = require('./About.js').default;
const template = require('./template.js');

function render(req, res) {
  const body = ReactDOMServer.renderToString(
    React.createElement(About),
  );
  res.send(template(body));
}

module.exports = render;

Listing 12-13ui/server/render.js: New File for Rendering About

现在,在uiserver.js中,我们可以将这个函数设置为带有/about路径的路由的处理程序。让我们在返回index.html的总括路线之前添加这条路线。这个变化,连同包含render.jsimport声明,显示在清单 12-14 中。

...
const proxy = require('http-proxy-middleware');

const render = require('./render.js');

...

app.get('/about', render);

app.get('*', (req, res) => {
  ...
});
...

Listing 12-14ui/server/uiserver.js: New Route for /About to Return Server-Rendered About

此时,About.js需要手工编译。使用npx babel命令,然后重启服务器。如果您将浏览器指向http://localhost:8000/about,您应该会看到没有任何导航栏装饰的About组件。这是因为我们用About组件替换了占位符${body},而没有布线的Page组件。图 12-3 显示了服务器端呈现的这个普通 About 页面的截图。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig3_HTML.jpg

图 12-3

服务器呈现了关于页面

虽然这与浏览器呈现的版本有一点不同(我们将在后面的部分中解决),但它足以证明相同的组件可以在服务器和浏览器上呈现。图 12-4 中的序列图详细解释了服务器渲染过程中发生的事情,其中包括一个从 API 服务器获取数据的步骤,这个步骤我们还没有实现。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig4_HTML.jpg

图 12-4

服务器渲染序列图

以下是导致服务器渲染的一系列步骤的说明:

  1. 用户键入“关于”页面的 URL(或在“关于”页面上选择浏览器中的刷新)。

  2. 浏览器向 UI 服务器发送对/about的请求。

  3. UI 服务器使用 GraphQL API 调用从 API 服务器获取页面所需的数据。我们将在本章的后面部分实现这一点。

  4. 在 UI 服务器上,用About组件及其数据调用ReactDOM.renderToString()

  5. 服务器返回一个 HTML,其中包含关于页面的标记。

  6. 浏览器将 HTML 转换为 DOM,并且“关于”页面在浏览器中可见。

练习:基本服务器渲染

  1. 假设服务器中呈现的组件的字符串表示非常大。从内存中的模板创建一个字符串会占用大量内存,并且速度很慢。在这种情况下你有什么选择?提示:在 https://reactjs.org/docs/react-dom-server.html 查阅ReactDOMServer的文档,看看还有哪些方法可用。

本章末尾有答案。

服务器的 Webpack

此时,我们只有一个简单的About组件。我们将需要它通过调用about API 来获取数据,并在服务器上呈现数据。我们将在接下来的章节中完成所有这些,但在此之前,让我们先解决不得不从About.jsx手动编译About.js的不便。很快,我们将不得不编译src目录下的所有文件以包含在服务器端,手动编译将变得不可行。

此外,您还看到了import/export范式和require/module.exports范式虽然兼容,但混合使用时并不方便。人们需要记住在使用import/export范例的文件的每个require()之后添加.default

事实证明,Webpack 也可以用于服务器,它可以动态编译 JSX。这也将让我们在 UI 服务器代码库中一致地使用import/export范例。Webpack 的工作方式与前端代码非常相似,但有一点不同。许多服务器端 Node 包(如 Express)与 Webpack 不兼容。它们动态地导入其他包,使得 Webpack 很难遵循依赖链。因此,我们必须从捆绑包中排除第三方库,并依赖于在 UI 服务器的文件系统中存在的node_modules

我们要做的第一件事是在webpack.config.js文件中为服务器配置添加一个新的部分。Webpack 允许导出一系列配置。当遇到一个数组时,Webpack 执行这个数组中的所有配置。新的webpack.config.js文件的轮廓是这样的:

...
const browserConfig = {
  mode: 'development',
  entry: { app: ['./browser/App.jsx'] },
  ...
}

const serverConfig = {
  mode: 'development',
  entry: { server: ['./server/uiserver.js'] },
  ...
}

module.exports = [browserConfig, serverConfig];
...

browserConfig变量包含原始配置内容。在服务器和浏览器之间使用共享文件的一个问题是,我们不能使用相同的巴别塔配置。为 Node.js 编译时,目标只是 Node 的最新版本,而为浏览器编译时,它需要一个浏览器版本列表。因此,让我们去掉srcbrowser目录中的.babelrc,转而通过 Webpack 配置 Babel 选项。这样,我们可以根据目标告诉 Babel 使用什么选项:浏览器还是服务器。

$ cd ui
$ rm src/.babelrc browser/.babelrc

现在,在webpack.config.jsbrowserConfig部分,我们可以这样指定这些选项:

...
        use: 'babel-loader',
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: {
                  ie: '11',
                  ...
                },
              }],
              '@babel/preset-react',
            ],
         },
       },
...

对于服务器配置,我们需要一个输出规范。让我们将这个包编译到一个名为dist(distribution 的简称)的新目录中,并将这个包称为server.js

...
const serverConfig = {
  ...
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
...

至于服务器的 Babel 配置,让我们将所有的jsjsx文件编译到 target Node.js 版本 10,并包含 React 预置。

...
const serverConfig = {
  ...
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: { node: '10' },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
...

这种配置不会起作用,因为它不排除node_modules中的模块。我们可以使用与浏览器配置相同的策略,但是在服务器上这样做的推荐方式是使用webpack-node-externals模块,这样效果更好。让我们安装这个包。

$ cd ui
$ npm install --save-dev webpack-node-externals@1

现在,在 Webpack 配置中,我们可以导入此包并在配置的服务器部分使用它,如下所示:

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

const nodeExternals = require('webpack-node-externals');

...
const serverConfig = {
  ...
  target: 'node',
  externals: [nodeExternals()],
  output: {
...

webpack.config.js的最终内容如清单 12-15 所示。这包括一个我没有明确提到的服务器配置的源映射规范。此外,为了简洁起见,我没有显示删除的代码:列出了文件的全部内容。

const path = require('path');
const nodeExternals = require('webpack-node-externals');

const browserConfig = {
  mode: 'development',
  entry: { app: ['./browser/App.jsx'] },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'public'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {

                targets: {
                  ie: '11',
                  edge: '15',
                  safari: '10',
                  firefox: '50',
                  chrome: '49',
                },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
  optimization: {
    splitChunks: {
      name: 'vendor',
      chunks: 'all',
    },
  },
  devtool: 'source-map',

};

const serverConfig = {
  mode: 'development',
  entry: { server: ['./server/uiserver.js'] },
  target: 'node',
  externals: [nodeExternals()],
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: { node: '10' },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
  devtool: 'source-map',
};

module.exports = [browserConfig, serverConfig];

Listing 12-15ui/webpack.config.js: Full Listing of File for Including Server Configuration

现在,我们准备将所有的require()语句转换成import语句。但是在我们这样做之前,因为我们偏离了在导入中不指定扩展名的规范,所以我们必须为此禁用 ESLint 设置。对服务器端.eslintrc的这一更改如清单 12-16 所示。

...
  "rules": {
    "no-console": "off",
    "import/extensions": "off"
  }
...

Listing 12-16ui/server/.eslintrc: Disable Import Extensions Rule

让我们首先将template.js改为使用导入/导出范例。清单 12-17 中显示了对此的更改。

...
export default function template(body) {
  ...
}
...

module.exports = template;

...

Listing 12-17ui/server/template.js: Use Import/Export

至于render.js,让我们把所有的require()语句都改成import语句。此外,现在我们可以在服务器端处理 JSX 作为绑定过程的一部分,让我们用 JSX 替换React.createElement(),并更改文件的扩展名以反映这一事实。

$ cd ui
$ mv server/render.js server/render.jsx

render.jsx文件的新内容如清单 12-18 所示。

import React from 'react';
import ReactDOMServer from 'react-dom/server';

import About from '../src/About.jsx';
import template from './template.js';

function render(req, res) {
  const body = ReactDOMServer.renderToString(<About />);
  res.send(template(body));
}

export default render;

Listing 12-18ui/server/render.jsx: New File for Rendering, Using JSX

在主服务器文件uiserver.js中,除了导入更改的require()语句,我们还需要更改加载初始配置的 HMR 初始化例程。既然配置导出了一个数组,我们将使用该数组中的第一个配置,而不是原样使用该配置。

...
  const config = require('../webpack.config.js')[0];
...

因为我们现在正在执行一个包,所以当在服务器上遇到任何错误时,堆栈跟踪中显示的行号就是这个包的行号。当我们遇到错误时,这一点也不方便。source-map-support模块解决了这个问题。在前端,source-map support模块也方便了添加断点。在后端,它所做的只是让错误消息更易读。

让我们安装source-map-support包:

$ cd ui
$ npm install source-map-support@0

我们现在可以在主服务器文件uiserver.js中安装这种支持,如下所示:

...
import SourceMapSupport from 'source-map-support';
...
SourceMapSupport.install();
...

uiserver.js的最终更改如清单 12-19 所示。

...

require('dotenv').config();

const path = require('path');

const express = require('express');

const proxy = require('http-proxy-middleware');

const render = require('./render.js');

import dotenv from 'dotenv';

import path from 'path';

import express from 'express';

import proxy from 'http-proxy-middleware';

import SourceMapSupport from 'source-map-support';

import render from './render.jsx';

const app = express();

SourceMapSupport.install();

dotenv.config();

...

  const config = require('../webpack.config.js')[0];
...

Listing 12-19ui/server/uiserver.js: Changes for Using Import, Source Maps, and Webpack Config Array Element

让我们更改package.json中的脚本部分,使用包来启动服务器,而不是文件uiserver.js。让我们也修改 ESLint 命令来反映新的目录结构。这些变化如清单 12-20 所示。

...
  "scripts": {
    ...
    "start": "nodemon -w server -w .env server/uiserver.js",
    "start": "nodemon -w dist -w .env dist/server.js",
    ...
    "lint": "eslint . --ext js,jsx --ignore-pattern public",
    "lint": "eslint server src browser --ext js,jsx",
    ...
  }
...

Listing 12-20ui/package.json: Changes for Scripts

手动生成的About.js文件已经不需要了,我们来清理一下。

$ cd ui
$ rm server/About.js

可以使用以下手动编译命令构建服务器包:

$ cd ui
$ npx webpack

现在,您可以使用npm start启动应用并检查它。应用的行为应该没有变化。您可以直接或者通过加载/issues并从那里导航来尝试 About 页面。这两个头像将继续不同,因为我们还没有返回一个带有导航栏的 HTML,等等。从服务器渲染时。

服务器的 HMR

尽管对服务器使用 Webpack 确实简化了编译过程,但您会发现在开发过程中,每次更改都需要重启服务器。您可以通过运行npm start来使用nodemon包装器,但是即使在那里您也会发现前端 HMR 并不工作。这是因为,在重启时,会重新安装 HMR 中间件,但是浏览器会尝试连接到原来的 HMR,但它已经不存在了。

所有这些问题的解决方案是使用 HMR 自动重新加载模块,即使是在后端。由于我们使用 Webpack 来捆绑服务器,这应该是可行的。但事实是,Express 已经存储了对任何现有模块的引用,当接受 HMR 更改时,需要告诉它替换这些模块。虽然这是可以做到的,但是设置起来相当复杂。

因此,让我们采取简单的方法:我们将只基于共享文件夹重新加载对模块的更改。至于对uiserver.js本身的更改,我们预计这种情况很少发生,所以让我们在这个文件更改时手动重启服务器,并对它包含的其余代码使用 HMR。

让我们首先创建一个新的 Webpack 配置,为服务器启用 HMR。这个配置应该不同于用于创建产品包的配置。对于浏览器,我们动态添加了 HMR 作为 UI 服务器的一部分(通过加载配置并在服务器代码中修改它)。但是由于在服务器代码的情况下我们没有服务器来服务这个包,所以我们必须创建一个单独的配置文件。与其复制整个配置文件并对其进行更改,不如让它基于原始配置并合并HMR 所需的更改。一个名为webpack-merge的包对此很有用。

$ cd ui
$ npm install --save-dev webpack-merge@4

让我们使用它将服务器配置上的 HMR 更改合并到一个名为webpack.serverHMR.js的新文件中。在这个文件中,让我们首先从主配置文件加载基本配置。请注意,服务器配置是数组中的第二个元素。

...
const serverConfig = require('./webpack.config.js')[1];
...

然后,让我们将serverConfig与新的变更合并:我们将添加一个新的入口点来轮询变更,我们将把 HMR 插件添加到这个配置中。完整的新文件如清单 12-21 所示。

/*
  eslint-disable import/no-extraneous-dependencies
*/
const webpack = require('webpack');
const merge = require('webpack-merge');
const serverConfig = require('./webpack.config.js')[1];

module.exports = merge(serverConfig, {
  entry: { server: ['./node_modules/webpack/hot/poll?1000'] },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ],
});

Listing 12-21ui/webpack.serverHMR.js: Merged Configuration for Server HMR

现在,如果您使用这个文件作为配置并使用watch选项来运行 Webpack,那么可以根据更改来重新构建服务器包。此外,它将让正在运行的服务器监听更改,并加载已更改的模块。使用此配置运行 Webpack 的命令如下:

$ cd ui
$ npx webpack -w --config webpack.serverHMR.js

但是,HMR 不会工作,因为服务器(还)不接受更改。如前所述,我们只接受对render.jsx的更改。因此,在uiserver.js中,我们可以在文件末尾添加以下内容:

...
if (module.hot) {
  module.hot.accept('./render.jsx');
}
...

但是,这样做的效果仅仅是加载已更改的模块并替换该文件中的变量render来引用新更改的模块。前往/about的快速路线仍然有一个旧的render功能的句柄。理想情况下,我们应该告诉 Express route 有一个新的render功能,可能是这样的:

...
if (module.hot) {
  module.hot.accept('./render.jsx', () => {
    app.get('/about', render);
  });
}

不幸的是,这会导致安装另一条路由,而不是替换现有的路由。在 Express 中也没有办法让卸载一条路由。为了解决这个问题,我们创建一个函数包装器并在其中显式调用render(),而不是将对函数的引用传递给快速路由处理程序。这样,被调用的render函数总是最新的。这一变化,连同模块接受变化,如清单 12-22 所示。

...
app.get('/env.js', (req, res) => {
  ...
});

app.get('/about', render);

app.get('/about', (req, res, next) => {

  render(req, res, next);

});

...

app.listen(port, () => {
  ...
});

if (module.hot) {

  module.hot.accept('./render.jsx');

}

...

Listing 12-22ui/server/uiserver.js: Changes for HMR

最后,让我们更改package.json的脚本部分,添加启动 UI 服务器的方便脚本。我们现在可以修改start脚本来删除 nodemon(因为 HMR 会自动加载模块)。然后,让我们用一个在观察模式下运行webpack.serverHMR.js配置的watch-server-hmr脚本来替换watch脚本。因为在开发模式下启动 UI 服务器需要这个脚本和启动脚本,所以让我们添加一个名为dev-all的脚本,一个接一个地完成这两项工作。

在 npm 脚本中,可以使用&操作符组合多个命令。这些命令是同时启动的。为了在运行npm start命令之前保护正在构建的server.js包,最好在运行npm start命令之前有一个sleep命令。等待的时间长短取决于计算机的速度和编译服务器文件所需的时间。首先,您可以使用五秒钟的睡眠计时器,并根据您的需要进行定制。

清单 12-23 显示了对package.json脚本的更改,但是脚本dev-all只在 MacOS 和 Linux 上工作。

...
  "scripts": {
    ...
    "start": "nodemon -w dist -w .env dist/server.js",
    "start": "node dist/server.js",
    ...
    "#watch": "Compile, and recompile on any changes.",
    "watch": "webpack --watch"
    "#watch-server-hmr": "Recompile server HMR bundle on changes.",
    "watch-server-hmr": "webpack -w --config webpack.serverHMR.js",
    "#dev-all": "Dev mode: watch for server changes and start UI server",
    "dev-all": "rm dist/* && npm run watch-server-hmr & sleep 5 && npm start"
  },
...

Listing 12-23ui/package.json: Changes to Scripts for HMR

在 Windows PC 上,您可能需要使用等效命令创建自己的批处理文件,或者在不同的命令窗口中执行npm watch-server-hmrnpm start

现在,您可以停止所有其他 UI 服务器命令,并使用单个npm run dev-all命令重新启动它。应用应该像以前一样工作,但大多数更改应该会自动反映出来,而不必重新启动该命令。

服务器路由

从服务器呈现 About 页面的方式不同于从/issues导航到该页面的方式。在第一种情况下,它在没有导航栏的情况下显示,在第二种情况下,它在有导航栏的情况下显示。

发生这种情况的原因如下。在浏览器上,App.jsxPage组件挂载到contents div 上。但是,在服务器上,About组件通过填充在模板中直接呈现在contents div 中。

在服务器上,用一个Router包围页面,或者使用SwitchNavLink s,都会抛出错误。这是因为Router实际上是为 DOM 准备的,在 DOM 中,单击一个路由的链接,浏览器的历史被操纵,不同的组件基于路由规则被加载。

在服务器上,React Router 建议我们使用StaticRouter来代替BrowserRouter。此外,BrowserRouter查看浏览器的 URL,而StaticRouter必须被提供 URL。基于此,路由将选择适当的组件进行渲染。StaticRouter获取一个名为location的属性,这是一个静态 URL,其余的渲染将需要它。它还需要一个名为context的属性,现在它的用途还不明显,所以我们只为它提供一个空对象。

然后让我们修改render.js来呈现Page组件,而不是About组件,但是被StaticRouter所包围。对此的更改如清单 12-24 所示。

...

import { StaticRouter } from 'react-router-dom';

import About from '../src/About.jsx';

import Page from '../src/Page.jsx';

...

function render(req, res) {
  const body = ReactDOMServer.renderToString(<About />);
  const element = (
    <StaticRouter location={req.url} context={{}}>
      <Page />
    </StaticRouter>
  );
  const body = ReactDOMServer.renderToString(element);
  res.send(template(body));
}
...

Listing 12-24ui/server/render.jsx: Changes to Render Page Instead of About Directly

现在,如果您测试应用,您将发现服务器呈现和浏览器呈现对于 About 页面是相同的:导航栏将在两种情况下出现。要测试服务器呈现,您需要在“关于”页面上按浏览器上的“刷新”。至于浏览器呈现,您需要在另一个页面中刷新浏览器,比如问题列表页面,然后使用扩展菜单导航到“关于”页面。请参考图 12-1 中的截图,回顾一下它的外观。

请注意,此时,除了 About 页面,其他页面仅在浏览器上呈现,即使在刷新时也是如此。一旦我们完善了关于页面服务器的渲染,我们将很快解决这个问题。

练习:服务器路由

  1. 在“关于”页面上按浏览器上的“刷新”,使用服务器呈现显示页面。尝试通过单击导航栏中的“创建问题”菜单项(+图标)来创建新问题。会发生什么?你能解释这个吗?提示:(a)尝试在IssueAddNavItemshowModal()方法中放置一个断点,然后(b)使用浏览器的开发工具检查+图标。检查附加到它的事件侦听器。点击主页菜单后尝试这些,并注意不同之处。

  2. 在“关于”页面上按浏览器上的“刷新”,使用服务器呈现显示页面。使用开发人员工具检查网络调用,然后导航到任何其他页面,例如问题列表页面。从报告页面而不是“关于”页面开始,执行相同的操作。你看到了什么不同,为什么?

本章末尾有答案。

水合物

尽管该页面看起来像它现在应该的样子,但仍然存在问题。如果您尝试了上一节末尾的练习,您会发现呈现的是纯 HTML 标记,没有任何 JavaScript 代码或事件处理程序。因此,在页面中不可能有用户交互。

为了附加事件处理程序,我们必须包含源代码,并让 React 控制呈现的页面。方法是加载 React 并让它呈现页面,就像在使用ReactDOM.render()进行浏览器呈现时一样。因为我们没有在模板中包含 JavaScript 包,所以它没有被调用,因此 React 没有获得页面的控制权。因此,让我们将脚本添加到服务页面,就像在index.html中一样,看看会发生什么。对模板的更改如清单 12-25 所示。

...
<body>
  <!-- Page generated from template. -->
  <div id="contents">${body}</div>

  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>
...

Listing 12-25ui/server/template.js: Include Browser Bundles

现在,如果您通过刷新 About 页面来测试应用,您会发现+按钮起作用了!这意味着已经附加了事件处理程序。但是您还会在控制台上看到这样的警告:

警告:render():调用ReactDOM.render()合并服务器呈现的标记将在 React v17 中停止工作。如果您希望 React 附加到服务器 HTML,请用ReactDOM.hydrate()替换ReactDOM.render()调用。

因此,React 区分了呈现 DOM 以替换 DOM 元素和将事件处理程序附加到服务器呈现的 DOM。让我们按照警告的建议将render()改为hydrate()。清单 12-26 中显示了对App.jsx的更改。

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

Listing 12-26ui/browser/App.jsx: Change Render to Hydrate

在测试这一更改时,您会发现警告已经消失,并且所有事件处理程序都已安装。您不仅可以在单击+按钮时看到效果,还可以在导航到导航栏中的其他选项卡时看到效果。以前,这些会导致浏览器刷新,而现在这些导航会直接将适当的组件加载到 DOM 中,React Router 发挥了它的魔力。

这一步完成了事件的服务器呈现序列,为了完成,服务器呈现的序列图需要这最后一步。新的顺序图如图 12-5 所示。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig5_HTML.jpg

图 12-5

使用水合物更新的服务器渲染序列图

对图表的更改概括在以下步骤中:

  1. 服务器返回应用和 React 以及其他库源代码包的脚本标签,而不是普通的About组件。

  2. “关于”页面是可查看的,但不是交互式的。这里的执行并没有真正的改变,只是图中明确声明了页面不是交互式的。

  3. 浏览器获取 JavaScript 包并执行它们。作为其中的一部分,ReactDOM.hydrate()以被路由的页面作为根元素执行。

  4. ReactDOM. hydrate()导致事件处理程序被附加到所有组件,现在页面是可见的交互的。

来自 API 的数据

我们在About组件中使用了硬编码的消息。实际上,这个字符串应该来自 API 服务器。具体来说,about API 的结果应该显示在 API 的硬编码版本字符串的位置。

如果我们遵循与从 API 加载数据的其他组件相同的模式,我们将在生命周期方法componentDidMount()中实现数据获取并设置组件的状态。但是在这种情况下,当组件在服务器上呈现时,我们真的需要 API 的返回值可用。

这意味着我们也需要能够从服务器通过graphQLFetch()向 API 服务器发出请求。在此期间,这个函数假设它是从浏览器调用的。这需要改变。首先,我们需要用既可以在浏览器上使用又可以在 Node.js 上使用的东西来替换whatwg-fetch模块。我们将使用名为isomorphic-fetch的包来实现这一点。所以让我们更换包装。

$ cd ui
$ npm uninstall whatwg-fetch
$ npm install isomorphic-fetch@2

现在,我们可以用isomorphic-fetch代替import whatwg-fetch。但是导入目前在App.jsx内,这是浏览器特有的。我们把它从那里去掉,在真正需要的地方加上isomorphic-fetch:graphQLFetch.js。对App.jsx的更改如清单 12-27 所示。

...

import 'whatwg-fetch';

...

Listing 12-27ui/browser/App.jsx: Removal of whatwg-fetch Import

graphQLFetch.js中,我们目前在window.ENV.UI_API_ENDPOINT中有 API 端点规范。这在服务器上不起作用,因为没有名为window的变量。我们需要使用process.env变量。但是我们没有任何东西来表明该函数是在浏览器中还是在 Node.js 中被调用。Webpack 的插件DefinePlugin可以用来定义运行时可用的全局变量。我们在第八章“模块化和 Webpack”的末尾已经简要讨论过这个插件,但是没有使用它。现在让我们使用这个插件来定义一个名为__isBrowser__的变量,它在浏览器包中被设置为true,但在服务器包中被设置为false。清单 12-28 显示了在webpack.config.js中定义该变量的变化。

...

const webpack = require('webpack');

...
const browserConfig = {
  ...
  plugins: [
    new webpack.DefinePlugin({
      __isBrowser__: 'true',
    }),
  ],
  devtool: 'source-map',
};

const serverConfig = {
  ...
  plugins: [
    new webpack.DefinePlugin({
      __isBrowser__: 'false',
    }),
  ],
  devtool: 'source-map',
};

Listing 12-28ui/webpack.config.js: DefinePlugin and Setting __isBrowser__

uiserver.js文件中,让我们设置process.env中的变量,如果它还没有设置的话。这是为了让其他模块可以获得这个配置变量,而不必担心默认值。此外,在代理操作模式中,浏览器和服务器 API 端点需要不同。浏览器需要为 API 使用 UI 服务器,API 服务器将被代理到 API 服务器,而服务器需要直接调用 API 服务器。让我们为 UI 服务器的 API 端点引入一个新的环境变量,称为UI_SERVER_API_ENDPOINT。如果没有指定,我们可以将其默认为与UI_API_ENDPOINT相同的端点。对此的更改如清单 12-29 所示。

...
if (apiProxyTarget) {
  ...
}

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT

  || 'http://localhost:3000/graphql';

const env = { UI_API_ENDPOINT };

if (!process.env.UI_API_ENDPOINT) {

  process.env.UI_API_ENDPOINT = 'http://localhost:3000/graphql';

}

if (!process.env.UI_SERVER_API_ENDPOINT) {

  process.env.UI_API_ENDPOINT = process.env.UI_API_ENDPOINT;

}

app.get('/env.js', (req, res) => {
  const env = { UI_API_ENDPOINT: process.env.UI_API_ENDPOINT };
  res.send(`window.ENV = ${JSON.stringify(env)}`);
});
...

Listing 12-29ui/server/uiserver.js: Set process.env Variable If Not Set

您可以将新的环境变量添加到您的.env文件中,但是因为我们正在使用非代理操作模式,所以您可以将其注释掉。清单 12-30 中的sample.env也有同样的变化。

...
UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

...

Listing 12-30ui/sample.env: Addition of Environment Variable for API Endpoint for Use by the UI Server

现在,我们可以更改graphQLFetch.js来从process.envwindow.ENV获得正确的 API 端点,这取决于它是运行在 Node.js 上还是浏览器上。对该文件的更改如清单 12-31 所示。

...
import fetch from 'isomorphic-fetch';
...

export default async function
graphQLFetch(query, variables = {}, showError = null) {
  const apiEndpoint = (__isBrowser__) // eslint-disable-line no-undef
    ? window.ENV.UI_API_ENDPOINT
    : process.env.UI_SERVER_API_ENDPOINT;
  try {
    const response = await fetch(window.ENV.UI_API_ENDPOINT apiEndpoint, {
      ...
  }
  ...
}
...

Listing 12-31ui/src/graphQLFetch.js: Using Isomorphic-Fetch and Conditional Configuration

现在,我们可以从服务器调用graphQLFetch()。在调用renderToString()之前,我们可以调用 About API 来获取数据,如下所示:

...
async function render(req, res) {
  const resultData = await graphQLFetch('query{about}');
...

但是,我们如何在呈现的同时将这些信息传递给About组件呢?一种方法是将它作为道具传递给Page组件,后者又将它传递给Contents组件,最后传递给About。但是这是一个障碍,并导致组件之间的过度耦合——PageContents都不需要知道只与About相关的数据。

这个问题的解决方案是使用一个全局存储所有需要渲染的组件层次结构所需的数据。让我们在一个名为store.js的文件中将这个存储创建为共享目录中的一个模块。这个存储的实现很简单:只是一个导出的空对象。该模块的用户可以通过导入该模块来分配全局可用的键值。新文件的内容如清单 12-32 所示。

const store = {};

export default store;

Listing 12-32ui/src/store.js: New Global Generic Storage Module (Complete Source)

现在,API 调用的结果可以保存在这个存储中。清单 12-33 中显示了对render.jsx的更改,以及对graphQLFetch()的调用以获取初始数据。

import template from './template.js';

import graphQLFetch from '../src/graphQLFetch.js';

import store from '../src/store.js';

async function render(req, res) {
  const initialData = await graphQLFetch('query{about}');
  store.initialData = initialData;
  ...
}
...

Listing 12-33ui/src/render.jsx: Changes for Saving the Data from an API Call

随着数据在全局存储中可用,我们现在可以更改About组件来从全局存储中读取它,以显示真实的 API 版本。让我们也通过检查存储是否存在来保护这一点;当在浏览器中构造相同的组件时,这可能很有用。清单 12-34 显示了对About.jsx的更改。

...
import React from 'react';

import store from './store.js';

export default function About() {
  ...
      <h4>
        API version 1.0
        {store.initialData ? store.initialData.about : 'unknown'}
      </h4>
  ...
}
...

Listing 12-34ui/src/About.jsx: Use Version Obtained from the API Call Via a Global Store

如果您对此进行测试,您会惊讶地发现 About 页面将 API 版本显示为“unknown ”,而不是从 API 获取的值。查看一下页面的源代码(使用开发工具检查页面源代码),您会发现 HTML 确实有来自服务器的 API 版本字符串。那么,为什么它没有出现在屏幕上呢?

如果您查看开发人员控制台,您会看到如下错误消息:

警告:文本内容不匹配。服务器:“问题跟踪 API v1.0 版”客户端:“未知”

这应该给你一个关于潜在问题的提示。我们将在下一节讨论这个问题。

同步初始数据

上一节中的错误消息说ReactDOM.hydrate()生成的 DOM 和服务器呈现的 DOM 之间存在差异。在服务器上,我们使用 API 调用的结果来设置版本,但是当 React 试图在浏览器上使用hydrate()附加事件处理程序时,它在存储中没有找到任何值,因此出现了错误。以下是 React 文档中的一段引文:

React 期望在服务器和客户端之间呈现的内容是相同的。它可以修补文本内容中的差异,但是您应该将不匹配视为错误并修复它们。在开发模式下,React 会在水合过程中发出不匹配警告。在不匹配的情况下,不能保证属性差异“得到修补”。出于性能原因,这一点很重要,因为在大多数应用中,不匹配的情况很少发生,因此验证所有标记会非常昂贵。

仔细想想,很有道理。当水合(或者将事件处理程序附加到 DOM)时,如果由hydrate()调用生成的树与已经存在的、由服务器呈现的树不匹配,事情就会变得不明确。请注意,hydrate()只是render()的一个变种——它真正创建了一个带有事件处理程序的虚拟 DOM,可以与实际的 DOM 同步。

需要的是使浏览器呈现与服务器呈现相同。但是这要求使用相同的数据在服务器和浏览器上呈现组件。浏览器渲染期间的 API 调用(例如,在生命周期方法componentDidMount()中)不会剪切它,因为它是异步的。我们需要组件第一次在*?? 渲染时的数据。*

推荐的方法是将 API 调用产生的相同初始数据以脚本的形式传递给浏览器,并使用它来初始化全局存储。这样,当组件被呈现时,它将具有与在服务器上呈现的组件相同的数据。

要做的第一件事是改变模板,使其接受一个额外的参数(初始数据)并将其设置在一个<script>部分的全局变量中。因为我们必须将任何对象转换成有效 JavaScript 的字符串表示,所以让我们使用JSON.stringify()将数据转换成字符串。让我们称这个全局变量为__INITIAL_DATA__。双下划线表示这是一个特殊的全局变量,因为有其他模块,它不太可能与任何其他全局变量冲突。对template.js的更改如清单 12-35 所示。

...
export default function template(body, data) {
  ...
  <div id="contents">${body}</div>
  <script>window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script>
  ...
}
...

Listing 12-35ui/server/template.js: Include Initial Data as a Script

在服务器上呈现时,我们现在可以通过这个模板将初始数据传递给浏览器,这些数据与在服务器上呈现页面时使用的数据相同。清单 12-36 显示了render.jsx中对此的更改。

...
async function render(req, res) {
 ...
  const body = ReactDOMServer.renderToString(element);
  res.send(template(body, initialData));
}
...

Listing 12-36ui/server/render.jsx: Changes for Sending Initial Data to the Browser

在浏览器中,我们需要先将这个值设置为全局存储,这样当组件被渲染时,它就可以访问全局存储中的初始数据。我们可以在浏览器渲染开始的地方进行全局存储初始化,在App.jsx中。这一变化如清单 12-37 所示。

...
import Page from '../src/Page.jsx';

import store from '../src/store.js';

// eslint-disable-next-line no-underscore-dangle

store.initialData = window.__INITIAL_DATA__;

...

Listing 12-37ui/browser/App.jsx: Use Initial Data to Initialize the Store

现在,当组件在浏览器中呈现时,它在存储区中的初始数据与在服务器上呈现时相同。如果您在 About 页面中通过刷新浏览器来测试应用,您将发现 React 错误消息不再显示。这表明浏览器呈现的结果与服务器呈现的结果相匹配,从而允许 React 附加事件处理程序而没有任何不匹配。

其他页面仍然会显示错误,例如,/issues URL 会在控制台中抛出以下错误:

警告:期望服务器 HTML 在<div>中包含匹配的<div>

原始的index.html将被返回给 URL /issues,它的主体中只有一个空的<div>,因为它不像 About 页面那样由服务器呈现。当 React 在调用hydrate()期间在浏览器中呈现 DOM 时,呈现的是实际的页面。因此,服务器呈现的内容和浏览器呈现的内容不匹配,因此会出现错误。当我们以一种通用的方式同步所有页面的服务器和浏览器数据时,我们将在本章的后面部分解决这个问题。

公共数据提取器

此时,尽管使用指向/about的 URL 刷新浏览器效果很好,但是您会发现从任何其他页面(比如说/issues)开始导航到 About 页面时,不会显示来自 API 服务器的 API 版本。这是因为我们从来没有在About组件中添加一个数据提取器,该数据提取器可以用来填充它的消息,以处理它只安装在浏览器上的情况。

因此,就像其他组件一样,让我们在About组件中添加一个componentDidMount()方法。现在需要将它从无状态函数转换成常规组件。让我们使用一个状态变量来存储和显示 API 版本。我们称这个变量为apiAbout。让我们从全局存储中初始化构造函数中的这个变量,如果它有初始数据的话。

...
  constructor(props) {
    super(props);
    const apiAbout = store.initialData ? store.initialData.about : null;
    this.state = { apiAbout };
  }
...

如果初始数据丢失,这将把状态变量设置为null,当/issues页面被加载并且用户导航到 About 页面时就会出现这种情况。我们可以利用这个事实在componentDidMount()内部发起一个 API 调用。但是因为我们在render.jsx中进行了相同的 API 调用,所以让我们使用一个公共函数来获取可以由About组件和render.jsx文件共享的数据。最好的地方是在About组件本身,作为一个静态函数。

...
  static async fetchData() {
    const data = await graphQLFetch('query {about}');
    return data;
  }
...

现在,在componentDidMount()中,如果状态变量apiAbout还没有被构造函数初始化,那么可以在状态中获取和设置数据。

...
  async componentDidMount() {
    const { apiAbout } = this.state;
    if (apiAbout == null) {
      const data = await About.fetchData();
      this.setState({ apiAbout: data.about });
    }
  }
...

最后,在render()方法中,我们可以使用状态变量而不是来自存储的变量。清单 12-38 显示了所有这些更改后的About.jsx的完整源代码。

import React from 'react';
import store from './store.js';
import graphQLFetch from './graphQLFetch.js';

export default class About extends React.Component {
  static async fetchData() {
    const data = await graphQLFetch('query {about}');
    return data;
  }

  constructor(props) {
    super(props);
    const apiAbout = store.initialData ? store.initialData.about : null;
    this.state = { apiAbout };
  }

  async componentDidMount() {
    const { apiAbout } = this.state;
    if (apiAbout == null) {
      const data = await About.fetchData();
      this.setState({ apiAbout: data.about });
    }
  }

  render() {
    const { apiAbout } = this.state;
    return (
      <div className="text-center">
        <h3>Issue Tracker version 0.9</h3>
        <h4>
          {apiAbout}
        </h4>
      </div>
    );
  }
}

Listing 12-38ui/src/About.jsx: Replaced Contents of About.jsx for Loading Data

现在,render.jsx中的 GraphQL 查询可以替换为对About.fetchData()的调用。这一变化如清单 12-39 所示。

...

import graphQLFetch from '../src/graphQLFetch.js';

import About from '../src/About.jsx';

...

async function render(req, res) {
  const resultData = await graphQLFetch('query{about}');
  const resultData = About.fetchData();
...

Listing 12-39ui/server/render.jsx: Use Common Data Fetcher from About.jsx

更改之后,您可以测试应用,特别是加载主页或/issues,然后导航到 About 页面。您应该看到显示的是正确的 API 版本。您还可以通过检查开发人员工具的 Network 选项卡来确认正在调用 API。

生成的路线

在这一节中,我们将修复 React 为其余页面显示的不匹配错误。我们还将建立一个框架,以一种通用的方式处理获取数据,这样我们就可以删除对render.jsx中的About.fetchData()的调用,并让它获取适合于将在页面中实际呈现的组件的数据。

首先,我们不返回index.html,而是使用所有页面的模板返回服务器呈现的 HTML。对此的改变是在处理路径/about的快速路由中。让我们用一个*来替换它,以指示任何路径都应该返回模板化的 HTML,而不是来自公共目录的文件index.html。这一变化如清单 12-40 所示。

...

import path from 'path';

...

app.get('/about', (req, res, next) => {

app.get('*', (req, res, next) => {

  render(req, res, next);
});

app.get('*', (req, res) => {

  res.sendFile(path.resolve('public/index.html'));

});

Listing 12-40ui/server/uiserver.js: Return Templated HTML for Any Path

由于不再需要index.html,我们可以删除这个文件。

$ cd ui
$ rm public/index.html

这一更改需要重启服务器,因为 HMR 本身不处理对uiserver.js的更改。在测试应用时,您会发现所有页面的不匹配<div>的 React 错误不再出现。如果你检查页面源码,你会发现服务器返回一个完整的页面,带有导航条等。,但是没有数据。

例如,当您刷新页面/issues时,您将看到表格标题存在,但是表格本身并没有填充问题。它与浏览器渲染相匹配,因为即使在浏览器中,初始渲染也是从一组空问题开始的。只有在componentDidMount()期间,问题列表才会从 API 中取出并填充到表格中。我们将在接下来的章节中解决这个问题。现在,让我们确保我们有能力根据匹配的路由来确定需要获取什么数据。

我们需要解决的主要问题是,通过 API 调用所需的数据需要在服务器上启动渲染之前可用。做到这一点的唯一方法是为可用的路由列表保留一个共同的真实来源。然后,我们可以将请求的 URL 与每个路由进行匹配,并找出哪个组件(以及哪个fetchData()方法)将匹配。相同的真实来源也应该负责在渲染期间生成实际的<Route>组件。

让我们将这个可路由页面的列表保存在一个名为routes.js的新文件中的 JavaScript 数组中。这可以是一个简单的数组,其中包含路由的路径以及在路由与 URL 匹配时需要呈现的组件。这个新文件如清单 12-41 所示。

import IssueList from './IssueList.jsx';
import IssueReport from './IssueReport.jsx';
import IssueEdit from './IssueEdit.jsx';
import About from './About.jsx';
import NotFound from './NotFound.jsx';

const routes = [
  { path: '/issues', component: IssueList },
  { path: '/edit/:id', component: IssueEdit },
  { path: '/report', component: IssueReport },
  { path: '/about', component: About },
  { path: '*', component: NotFound },
];

export default routes;

Listing 12-41ui/src/routes.js: New File to Store Route Metadata

我们已经将NotFound作为一个组件导入并使用,但是它被定义为Contents.jsx的一部分,这是行不通的。让我们把它分离出来并为它创建一个新文件,如清单 12-42 所示。

import React from 'react';

function NotFound() {
  return <h1>Page Not Found</h1>;
}

export default NotFound;

Listing 12-42ui/src/NotFound.jsx: New File for the Page Not Found Component

我们现在可以修改Contents.jsx来从这个路由元数据数组生成<Route>组件。让我们映射数组并为每个组件返回一个<Route>,其属性与数组中每个对象的属性相同。React 还需要数组中每个元素的惟一键,这可以是 route 的路径,因为它必须是惟一的。对该文件的更改如清单 12-43 所示。

...

import IssueList from './IssueList.jsx';

import IssueReport from './IssueReport.jsx';

import IssueEdit from './IssueEdit.jsx';

import About from './About.jsx';

const NotFound = () => <h1>Page Not Found</h1>;

import routes from './routes.js';

export default function Contents() {
  return (
    <Switch>
      <Redirect exact from="/" to="/issues" />
      <Route path="/issues" component={IssueList} />
      <Route path="/edit/:id" component={IssueEdit} />
      <Route path="/report" component={IssueReport} />
      <Route path="/about" component={About} />
      <Route component={NotFound} />
      {routes.map(attrs => <Route {...attrs} key={attrs.path} />)}
    </Switch>
  );
}
...

Listing 12-43ui/src/Contents.jsx: Changes to Generate Routes from routes.js Array

在服务器和浏览器上进行渲染时,将根据 URL 选择一条路线进行渲染。在浏览器上,BrowserRouter的历史对象将提供匹配的 URL,在服务器上,我们已经通过StaticRouterlocation属性提供了它,我们用它来包装页面。

我们仍然需要用更通用的东西替换对About.fetchData()的调用。要做到这一点,我们需要确定哪些组件将匹配通过render.jsx中的请求对象传入的当前 URL。React Router 公开了一个名为matchPath()的函数,正是为了这个目的:它匹配一个给定的 JavaScript 对象,这是一个路由规范,如routes.js中的数组所示:

...
  const match = matchPath(urlPath, routeObject)
...

routeObject对象应该包含属性pathexactstrict,就像定义一个<Route>组件一样。如果路线与提供的urlPath匹配,它返回一个match对象。因此,我们可以迭代来自routes.js的路由数组,并找到匹配的路由。

...
import routes from '../src/routes.js';
import { StaticRouter, matchPath } from 'react-router-dom';
...
  const activeRoute = routes.find(
    route => matchPath(req.path, route),
  );
...

如果有匹配,我们可以查看匹配的 route 对象的component属性,看看组件中是否定义了静态函数来获取数据。如果有,我们可以调用那个函数来获取初始数据。

...
  let initialData;
  if (activeRoute && activeRoute.component.fetchData) {
    initialData = await activeRoute.component.fetchData();
  }
...

这个初始数据现在可以代替对About.fetchData()的硬编码调用。清单 12-44 中显示了这一变更以及对render.jsx的变更。

...
import { StaticRouter, matchPath } from 'react-router-dom';
...

import About from '../src/About.jsx';

import store from '../src/store.js';

import routes from '../src/routes.js';

...

async function render(req, res) {
  const initialData = About.fetchData();
  const activeRoute = routes.find(
    route => matchPath(req.path, route),
  );

  let initialData;
  if (activeRoute && activeRoute.component.fetchData) {
    initialData = await activeRoute.component.fetchData();
  }
  ...
}

Listing 12-44ui/server/render.jsx: Changes for Fetching the Initial Data Depending on the Matched Route

有了这些改变,我们已经设法摆脱了数据的硬编码,这些数据需要根据将为匹配路线呈现的组件来获取。但是我们还没有在许多组件中实现数据获取。例如,在/issues刷新浏览器将继续呈现一个空表,该表稍后会填充从浏览器中的 API 调用获取的问题列表。这不是我们所需要的:对/issues的请求应该产生一个页面,其中充满了与过滤器匹配的问题列表。但是与我们为About组件所做的有细微差别:这些 API 调用根据参数的不同而不同。当我们开始在每个现有组件中实现数据提取器时,我们将探索如何传递这些参数以用于呈现。

带参数的数据提取器

在这一节中,我们将让IssueEdit组件从服务器呈现它需要预先填充的数据。

首先,让我们将数据获取器分成一个静态方法,就像我们在About组件中所做的那样。该方法依赖问题的 ID 来获取数据。拥有该信息的最普通的实体是组件在浏览器上呈现时自动访问的match对象。

在服务器上呈现时,matchPath()调用的结果给了我们相同的信息。所以,让我们改变fetchData()函数的原型,使其包含match。此外,由于在浏览器和服务器上显示错误的方法不同,我们也将显示错误的函数showError作为参数。然后,让我们将 GraphQL 查询从loadData()函数移到这个函数,并使用从match对象获得的问题 ID 来执行它。

...
  static async fetchData(match, showError) {
    const query = `...`;

    const { params: { id } } = match;
    const result = await graphQLFetch(query, { id }, showError);
    return result;
  }
...

作为构造函数的一部分,我们可以检查是否有任何初始数据,并使用它来初始化状态变量issue。此外,不使用空的issue对象,让我们将状态变量设置为null,以表明它不是从服务器预加载的。既然我们有多个组件在查看初始数据,那么在浏览器上呈现的组件的构造函数可能会混淆初始数据。因此,让我们在使用完数据后,将其从存储中删除。

...
  constructor() {
    super();
    const issue = store.initialData ? store.initialData.issue : null;
    delete store.initialData;
    this.state = {
      issue: {},
...

componentDidMount()方法中,我们现在可以寻找状态变量的存在。如果不是null,说明是从服务器渲染的。如果是null,这意味着用户从服务器加载的不同页面导航到浏览器中的这个组件。在这种情况下,我们可以使用fetchData()加载数据。

...
  componentDidMount() {
    const { issue } = this.state;
    if (issue == null) this.loadData();
  }
...

loadData()方法中,我们将把原来对graphQLfetch()的调用替换为对fetchData()的调用。

...
  async loadData() {
    const { match } = this.props;
    const data = await IssueEdit.fetchData(match, this.showError);
    this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
  }
...

既然我们将issue对象的值设置为null来表示一个预初始化状态,如果发布状态变量是null,那么让我们在render()方法中返回null

...
  render() {
    const { issue } = this.state;
    if (issue == null) return null;

    const { issue: { id } } = this.state;
    ...
  }
...

清单 12-45 中显示了对IssueEdit组件的完整更改。

...
import Toast from './Toast.jsx';

import store from './store.js';

...

export default class IssueEdit extends React.Component {
  static async fetchData(match, showError) {
    const query = `query issue($id: Int!) {
      issue(id: $id) {
        id title status owner
        effort created due description
      }
    }`;

    const { params: { id } } = match;
    const result = await graphQLFetch(query, { id }, showError);
    return result;
  }

  constructor() {
    super();
    const issue = store.initialData ? store.initialData.issue : null;
    delete store.initialData;
    this.state = {
      issue: {},
      issue,
      invalidFields: {},
      ...
    }
    ...
  }
  ...

  componentDidMount() {
    const { issue } = this.state;
    if (issue == null) this.loadData();

  }
  ...

  async loadData() {
    const query = `query issue($id: Int!) {
      ...
    }`;

    const { match: { params: { id } } } = this.props;
    const data = await graphQLFetch(query, { id }, this.showError);
    const { match } = this.props;
    const data = await IssueEdit.fetchData(match, this.showError);
    this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
  }
  ...

  render() {
    const { issue } = this.state;
    if (issue == null) return null;
    ...
  }
  ...
}
...

Listing 12-45ui/src/IssueEdit.jsx: Changes to Use a Common Data Fetcher

因为我们还有创建初始数据的About组件,所以让我们在使用它之后删除这个数据,就像我们在IssueEdit中所做的那样。对此的更改如清单 12-46 所示。

...
  constructor(props) {
    ...
    const apiAbout = store.initialData ? store.initialData.about : null;
    delete store.initialData;
    ...
   }
...

Listing 12-46ui/src/About.jsx: Add Deletion of initialData After Consumption

下一步是在服务器渲染时在render.jsx中传递match参数。我们可以在find()例程中保存matchPath()的结果,但是我选择在匹配后重新评估这个值。对render.jsx的更改如清单 12-47 所示。

...
  if (activeRoute && activeRoute.component.fetchData) {
    const match = matchPath(req.path, activeRoute);
    initialData = await activeRoute.component.fetchData(match);
  }
...

Listing 12-47ui/server/render.jsx: Changes to Include the Match Object in a Call to fetchData()

此时,如果您导航到任何问题的编辑页面并刷新浏览器,您将在开发人员控制台中发现如下错误:

Uncaught TypeError: created.toDateString is not a function

这是因为我们使用JSON.stringify()写出了issue对象的内容,它将日期转换为字符串。当我们通过graphQLFetch进行 API 调用时,我们使用了 JSON date reviver 函数将字符串转换为 date 对象,但是初始化数据的脚本没有使用JSON.parse()。该脚本按原样执行。你可以在浏览器中使用 View Page Source 查看源文件,你会发现键created被设置为一个字符串。

解决方案是序列化初始化数据的内容,使其成为正确的 JavaScript(创建的属性将被赋予new Date(...))。为此,我们现在可以安装一个名为serialize-javascript的包:

$ cd ui
$ npm install serialize-javascript@1

现在,让我们用serialize函数替换template.js中的JSON.stringify()。对此的更改如清单 12-48 所示。

...

import serialize from 'serialize-javascript';

...

  <script>window.__INITIAL_DATA__ = ${JSON.stringify serialize(data)}</script>
...

Listing 12-48ui/server/template.js: Use Serialize Instead of Stringify

如果您在更改后测试应用,您会发现错误消息消失了,问题编辑页面正确地显示了问题,包括日期字段。此外,如果您导航到 About 页面,您应该会看到它从 API 服务器加载 API 版本(开发人员工具的 Network 选项卡会显示这一点)。这证明了一个页面的初始数据不会影响另一个页面的呈现。

练习:带参数的数据提取器

  1. 有没有办法对初始数据也使用 JSON 日期回顾策略?反过来怎么样:有没有办法在 API 调用中使用serialize()而不是JSON.parse()?我们应该这样做吗?

本章末尾有答案

带搜索的数据提取器

在这一节中,我们将在IssueList组件中实现数据获取器。在IssueEdit组件中,我们处理了数据获取器需要匹配路线的参数这一事实。在IssueList中,我们将处理这样一个事实,即 URL 的搜索(查询)字符串部分是获取正确问题集所必需的。

让我们将查询字符串(React Router 调用这个search)以及match对象传递给fetchData()。在服务器中,我们不能直接访问这个值,所以我们必须搜索?字符,并在请求的 URL 上使用子串操作来获得这个值。清单 12-49 显示了render.jsx对此的更改。

...
  if (activeRoute && activeRoute.component.fetchData) {
    const match = matchPath(req.path, activeRoute);
    const index = req.url.indexOf('?');
    const search = index !== -1 ? req.url.substr(index) : null;
    initialData = await activeRoute.component.fetchData(match, search);
  }
...

Listing 12-49ui/server/render.jsx: Include Search String in fetchData() Calls

由于这一变化,我们还必须修改IssueEdit以在它的静态fetchData()方法中包含这个新参数。对此的更改如清单 12-50 所示。

...
export default class IssueEdit extends React.Component {
  static async fetchData(match, search, showError) {
    ...
  }
  ...
  async loadData() {
    ...
    const data = await IssueEdit.fetchData(match, null, this.showError);
    ...
  }
  ...
}
...

Listing 12-50ui/src/IssueEdit: Changes for Change in fetchData() Prototype

现在,让我们在IssueList组件中创建数据获取器。我们将把所需的代码从loadData()方法移到这个新的静态方法中。以下变化与loadData()方法中的原始代码相比。有关该方法的完整列表,请参考列表 12-51 。

...
  static async fetchData(match, search, showError) {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    ...

    const query = `query issueList(
      ...
    }`;

    const data = await graphQLFetch(query, vars, this.showError);
    return data;
  }
...

loadData()方法现在将使用这个数据提取器,而不是直接进行查询。已经移动到fetchData()的代码没有显示为明确删除;这是这个方法的完整代码。

...
  async loadData() {
    const { location: { search } } = this.props;
    const data = await IssueList.fetchData(null, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }
...

在构造函数中,我们将使用存储和初始数据来设置初始问题集,并在用完之后将其删除。

...
  constructor() {
    super();
    const issues = store.initialData ? store.initialData.issueList : null;
    delete store.initialData;
    this.state = {
      issues: [],
      issues,
...

当组件被安装时,如果状态变量有一组有效的问题,即它不是null,我们可以避免加载数据。

...
  componentDidMount() {
    const { issues } = this.state;
    if (issues == null) this.loadData();
  }
...

最后,和在IssueEdit组件中一样,如果状态变量issues被设置为null,让我们跳过渲染。清单 12-51 中显示了对该组件的一整套更改,包括最后的更改。

...
import Toast from './Toast.jsx';

import store from './store.js';

...

export default class IssueList extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = {};
    if (params.get('status')) vars.status = params.get('status');

    const effortMin = parseInt(params.get('effortMin'), 10);
    if (!Number.isNaN(effortMin)) vars.effortMin = effortMin;
    const effortMax = parseInt(params.get('effortMax'), 10);
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueList(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        id title status owner
        created effort due
      }
    }`;

    const data = await graphQLFetch(query, vars, showError);
    return data;
  }

  constructor() {

    super();
    const issues = store.initialData ? store.initialData.issueList : null;
    delete store.initialData;
    this.state = {
      issues: [],
      issues,
      ...
    };
    ...
  }
...

  componentDidMount() {
    const { issues } = this.state;
    if (issues == null) this.loadData();
  }
...

  async loadData() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    ...
    const data = await graphQLFetch(query, vars, this.showError);
    const data = await IssueList.fetchData(null, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList });

    }
  }
...

  render() {
    const { issues } = this.state;
    if (issues == null) return null;
    ...
  }
...
}

Listing 12-51ui/src/IssueList.jsx: Changes for Data Fetcher Using Search

如果您现在测试应用,尤其是问题列表页面,您应该会发现问题列表的刷新显示了表中填写的问题。这可以通过检查页面的源代码来确认:您应该发现该表是预填充的。此外,如果您查看开发人员工具的网络选项卡,您应该不会看到任何 API 调用来获取刷新时的问题列表,而 API 调用将在从任何其他页面导航时进行。还要使用不同的过滤器值进行测试,以确保正确使用搜索字符串。

嵌套组件

我们还有一个组件需要处理:IssueDetail。此时,无论是单击列表中的某个问题行,还是使用包含问题 ID(如/issues/1)的 URL 刷新浏览器,组件似乎都可以工作。但是你会发现细节是在挂载组件之后获取的,而不是作为服务器渲染 HTML 的一部分。如前所述,这并不好。我们真的需要细节部分也在服务器上呈现。

虽然 React Router 的动态路由在通过 UI 中的链接导航时工作得很好,但在服务器渲染时却很不方便。我们不容易处理嵌套路由。一种选择是在routes.js中添加路由嵌套,并将嵌套的 route 对象传递给包含组件,以便它可以基于此在适当的位置创建一个<Route>组件。

另一种选择是我们在第九章的“嵌套路由”练习中讨论过的。在这个替代方案中,IssueList的路由规范包括一个可选的问题 ID,这个组件也处理细节部分的加载。这具有以下优点:

  • 路由规范仍然很简单,只有平面结构的顶层页面,没有任何层次结构。

  • 在问题列表加载了所选问题的情况下,它为我们提供了将两个 API 调用合并为一个的机会。

让我们选择这个选项,并修改组件IssueList来呈现它所包含的细节。这将导致IssueDetail组件被大大简化,使其成为一个无状态组件,只呈现细节。新的IssueDetail组件的完整代码如清单 12-52 所示。

import React from 'react';

export default function IssueDetail({ issue }) {
  if (issue) {
    return (
      <div>
        <h3>Description</h3>
        <pre>{issue.description}</pre>
      </div>
    );
  }
  return null;
}

Listing 12-52ui/src/IssueDetail.jsx: Replaced Contents with a Stateless Component

让我们也修改路由来指定 ID 参数,这是可选的。指定参数是可选的方法是给它附加一个?。清单 12-53 显示了routes.js的变更。

...
const routes = [
  { path: '/issues/:id?', component: IssueList },
  ...
];
...

Listing 12-53ui/src/routes.js: Modification of /issues Route to Include an Optional Parameter

现在,在IssueList组件中,我们将在props.match中找到所选问题的 ID。同样,在fetchData()中,我们将使用 match 对象来查找并使用所选问题的 ID。如果存在一个选定的 ID,我们将在一个graph QL 调用中获取它的详细信息和问题列表。GraphQL 允许在一个query中添加多个命名查询,所以我们将利用这一点。但是因为对问题细节的第二次调用是可选的,所以我们必须有条件地执行它。我们可以使用 GraphQL 的@include指令来实现这一点。我们将传递一个额外的变量hasSelection,如果这个变量的值是true,我们将包含第二个查询。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { hasSelection: false, selectedId: 0 };
    ...

    const { params: { id } } = match;
    const idInt = parseInt(id, 10);
    if (!Number.isNaN(idInt)) {
      vars.hasSelection = true;
      vars.selectedId = idInt;
    }

    const query = `query issueList(
      ...
      $hasSelection: Boolean!
      $selectedId: Int!
    ) {
      issueList(
        ...
      }
      issue(id: $selectedId) @include (if : $hasSelection) {
        id description
      }
    }`;

    const data = await graphQLFetch(query, vars, showError);
    return data;
  }
...

现在,当hasSelection被设置为true : issueListissue时,返回的data对象将有两个属性。同样的情况也会出现在store.initialData中,所以让我们使用额外的issue对象来设置构造函数中的初始状态。

...
  constructor() {
    ...
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    delete store.initialData;
    this.state = {
      issues,
      selectedIssue,
      ...
    };
...

我们需要对loadData()进行类似的更改:将match传递给fetchData(),然后使用结果来设置状态变量selectedIssueissues

...
  async loadData() {
    const { location: { search }, match } = this.props;
    const data = await IssueList.fetchData(nullmatch, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList, selectedIssue: data.issue });
    }
  }
...

现在我们可以在render()函数中使用新的状态变量selectedIssue来显示所需位置的细节,而不是一个<Route>

...
  render() {
    ...
    const { match } = this.props;
    const { selectedIssue } = this.state;
    return (
      ...
        <Route path={`${match.path}/:id`} component={IssueDetail} />
        <IssueDetail issue={selectedIssue} />
      ...
    );
  }
...

此时,刷新问题列表和更改过滤器将起作用,但选择新的问题行将不会反映详细信息部分中的更改。这是因为componentDidUpdate()只检查搜索中的变化并重新加载数据。我们还需要检查要重新加载的所选问题的 ID 是否有变化。

...
  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const {
      location: { search: prevSearch },
      match: { params: { id: prevId } },
    } = prevProps;
    const { location: { search }, match: { params: { id } } } = this.props;
    if (prevSearch !== search || prevId !== id) {
      this.loadData();
    }
  }
...

清单 12-54 中显示了对IssueList组件的一整套更改。

...
import URLSearchParams from 'url-search-params';

import { Route } from 'react-router-dom';

...

  static async fetchData(match, search, showError) {
    ...
    const vars = { hasSelection: false, selectedId: 0 };
    ...
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const { params: { id } } = match;
    const idInt = parseInt(id, 10);
    if (!Number.isNaN(idInt)) {
      vars.hasSelection = true;
      vars.selectedId = idInt;
    }

    const query = `query issueList(
      ...
      $hasSelection: Boolean!
      $selectedId: Int!
    ) {
      issueList(
        ...
      }
      issue(id: $selectedId) @include (if : $hasSelection) {
        id description
      }
    }`;
    ...
  }
...

  constructor() {
    ...
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    delete store.initialData;
    this.state = {
      issues,
      selectedIssue,
      ...
    };
...

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const {
      location: { search: prevSearch },
      match: { params: { id: prevId } },

    } = prevProps;
    const { location: { search }, match: { params: { id } } } = this.props;
    if (prevSearch !== search || prevId !== id) {
      this.loadData();
    }
  }
...

async loadData() {
    const { location: { search }, match } = this.props;
    const data = await IssueList.fetchData(nullmatch, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList, selectedIssue: data.issue });
    }
  }
...

render() {
    ...
    const { match } = this.props;
    const { selectedIssue } = this.state;
    return (
      ...
        <Route path={`${match.path}/:id`} component={IssueDetail} />
        <IssueDetail issue={selectedIssue} />

      ...
    );
  }
...

Listing 12-54ui/src/IssueList.jsx: Pull Up IssueDetail Into IssueList

如果您现在测试问题列表页面,尤其是在选择任何一个问题并刷新浏览器的情况下,您会发现所选问题的详细信息与问题列表一起加载。如果您通过单击另一行来更改所选问题,您将在 Developer Tools 的 Network 选项卡中看到,单个 GraphQL 调用正在获取问题列表以及所选问题的详细信息。

练习:嵌套构件

  1. 当更改所选问题时,尽管这只是一个 GraphQL 调用,但整个问题列表都将被获取。这不是必需的,并且会增加网络流量。您将如何为此进行优化?

本章末尾有答案

重新寄送

我们还有最后一件事要处理:对主页的请求,也就是/,从服务器返回一个包含一个空页面的 HTML。这似乎是可行的,因为在浏览器上渲染之后,React Router 会在浏览器历史中重定向到/issues 。我们真正需要的是服务器本身用 301 重定向来响应,这样浏览器就可以从服务器获取/issues。通过这种方式,搜索引擎机器人也将从对/的请求中获得与对/issues的请求相同的内容。

React 路由的StaticRouter通过在传递给它的任何上下文中设置一个名为url的变量来处理这个问题。我们一直在向StaticRouter传递一个空的、未命名的上下文。相反,让我们向它传递一个命名的对象,尽管它是空的。渲染成 string 后,如果在这个对象中设置了url属性,就意味着路由匹配到了一个重定向到这个 URL。我们需要做的就是在响应中发送一个重定向,而不是模板化的响应。

清单 12-55 显示了对render.jsx进行的处理重定向的更改。

...
  store.initialData = initialData;
  const context = {};
  const element = (
    <StaticRouter location={req.url} context={{}context}>
      <Page />
    </StaticRouter>
  );
  const body = ReactDOMServer.renderToString(element);

  if (context.url) {
    res.redirect(301, context.url);
  } else {
    res.send(template(body, initialData));
  }
...

Listing 12-55ui/server/render.jsx: Handle Redirects on the Server

现在,如果您在浏览器中输入http://localhost:8000/,您应该看到问题列表页面加载时没有任何闪烁。在 Network 选项卡中,您会发现第一个请求导致了到/issues的重定向,然后按照常规路径在服务器上呈现问题列表。

摘要

这一章可能有点沉重,因为我们使用了复杂的结构和模式来实现服务器渲染。希望使用 About 页面可以降低复杂性,并帮助您理解服务器渲染的基本概念。

现在很明显,React 本身不是一个框架,并不决定完成应用的每个附加部分。React Router 在前端路由方面帮了我们一点忙,但是有些并不适合服务器渲染。我们必须发明自己的生成路由模式和数据获取模式,作为每个路由组件中的静态方法,来处理与服务器呈现的页面相关的数据。

当我们进入下一章时,我们不会只关注一个单一的特性或概念。相反,我们将实现许多应用共有的特性。在此过程中,我们将了解 MERN 堆栈如何满足这些高级功能的需求。

练习答案

练习:基本服务器渲染

  1. 可以考虑用ReactDOMServerrenderToNodeStream()代替renderToString()。此方法返回一个可以通过管道传输到快速响应流的流。我们不需要模板,而是需要前体和后体字符串,我们可以分别在通过管道传输 Node 流之前和之后将它们写入响应。

练习:服务器路由

  1. 当使用服务器呈现来呈现 About 页面时,您会发现单击+菜单项没有任何作用。菜单项没有附加事件处理程序,此外,您会发现没有可以放置断点的代码。原因是模板不包括包含组件和 React 库代码的 JavaScript 包。

  2. 在浏览器呈现的导航栏中,单击链接不会从服务器加载页面。只进行 XHR 调用来获取数据,并且在浏览器上构造 DOM。在服务器呈现的导航栏中,点击一个链接从服务器加载页面,就像普通的href会做的那样。原因与前面的练习相同:在服务器呈现的页面中,没有附加事件处理程序来捕获 click 事件并在浏览器中对 DOM 进行更改。在服务器呈现的页面中,链接的行为就像纯href链接一样:它们让浏览器加载一个新页面。

练习:带参数的数据提取器

  1. 你可以使用JSON.parse(),但是它需要一个字符串作为参数。因为初始数据的字符串表示本身有许多双引号,所以需要对它们进行转义,或者可以用单引号将它们括起来。一些人使用的另一种策略是使用隐藏的textareadiv来存储字符串,并从 DOM 中读取它,然后在该字符串上调用JSON.parse()。我发现序列化是一个更简洁、更清晰的选择。

    相反,API 的调用者需要在结果数据上使用一个eval()而不是JSON.parse()。这是非常危险的,因为如果数据中包含任何新的函数,它将允许安装新的函数。如果 API 服务器由于某种原因遭到破坏,这可能会导致恶意代码被注入浏览器。此外,这种策略假设调用者使用 JavaScript,这可能不是一个有效的假设。

练习:嵌套构件

  1. 优化正在获取的数据的一个好策略是编写另一个方法,通过不同的 GraphQL 查询单独获取选定的问题。如果搜索没有改变,但是 id 参数改变了,那么这个方法,比如说loadSelectedIssue(),可以从componentDidUpdate()调用。