创建通用 JavaScript 应用程序

现在我们已经介绍了基础知识,让我们开始构建一个更完整的通用 JavaScript 应用程序。 我们将构建一个简单的 “图书库” 应用程序,在其中我们可以列出不同的作者并查看他们的传记和一些杰作。 虽然这将是一个非常简单的应用程序,但它将使我们能够涵盖更高级的主题,例如通用路由、通用渲染和通用数据获取。 这个想法是,您稍后可以使用此应用程序作为实际项目的支架,并在其之上构建您的下一个通用 JavaScript 应用程序。

在这个实验中,我们将使用以下技术:

  • React (nodejsdp.link/react),我们刚刚介绍

  • React Router (nodejsdp.link/react-router),React 的配套路由层

  • Fastify (nodejsdp.link/fastify),一个快速且符合人体工程学的构建框架 Node.js 中的 Web 服务器

  • Webpack 作为模块捆绑器

出于实际原因,我们为此练习选择了一组非常具体的技术,但我们将尽可能关注设计原则和模式,而不是技术本身。 当您学习这些模式时,您应该能够将获得的知识与任何其他技术组合一起使用并获得类似的结果。

为了简单起见,我们将仅使用 webpack 来处理前端代码,而后端代码将保持不变,利用对 ESM 的原生 Node.js 支持。

在撰写本文时,webpack 解释 ESM 导入语义的方式与 Node.js 的解释方式之间存在一些细微的差异,特别是在导入使用 CommonJS 语法编写的模块时。 因此,我们建议使用 esm (nodejsdp.link/esm) 运行本章其余部分中的示例,这是一个 Node.js 库,它将以最小化这些差异的方式预处理 ESM 导入。 在项目中安装 esm 模块后,您可以使用 esm 运行脚本,如下所示:

node –r esm script.js

仅前端应用程序

在本节中,我们将专注于仅在前端构建我们的应用程序,使用 webpack 作为开发 Web 服务器。 在接下来的部分中,我们将扩展和更新这个基本应用程序,将其转换为完整的通用 JavaScript 应用程序。

这次,我们将使用自定义 webpack 配置,因此我们首先创建一个新文件夹,然后从本书提供的代码存储库 (nodejsdp.link/frontend-only-) 复制 package.json 和 webpack.config.cjs 文件。 app),然后安装所有必要的依赖项:

npm install

我们将使用的数据存储在 JavaScript 文件中(作为数据库的简单替代品),因此请确保将文件 data/authors.js 也复制到您的项目中。 该文件包含一些以下格式的示例数据:

export const authors = [
    {
        id: 'author\'s unique id',
        name: 'author\'s name',
        bio: 'author\'s biography',
        books: [ // author's books
            {
                id: 'book unique id',
                title: 'book title',
                year: 1914 // book publishing year
            },
            // ... more books
        ]
    },
    // ... more authors
]

当然,如果您想添加您最喜欢的作者和书籍,请随意更改此文件中的数据!

现在我们已经完成了所有配置,让我们快速讨论一下我们希望我们的应用程序是什么样子。

image 2024 05 07 18 17 53 223
Figure 1. 图 10.3:应用程序模型

图 10.3 显示我们的应用程序将有两种不同类型的页面:一个索引页面,我们在其中列出数据存储中可用的所有作者,然后是一个可视化给定作者详细信息的页面,我们将在其中看到他们的传记和 他们的一些书。

这两种类型的页面只有一个共同的标题。 这将使我们可以随时返回索引页面。

我们将在服务器的根路径 (/) 公开索引页面,同时我们将使用路径 /author/:authorId 作为作者页面。

最后,我们还会有一个 404 页面。

在文件结构方面,我们将按如下方式组织我们的项目:

src
├── data
│     └── authors.js – data file
└── frontend
      ├── App.js – application component
      ├── components
      │     ├── Header.js – header component
      │     └── pages
      │           ├── Author.js – author page
      │           ├── AuthorsIndex.js – index page
      │           └── FourOhFour.js – 404 page
      └── index.js – project entry point

让我们从编写 index.js 模块开始,它将作为加载前端应用程序并将其附加到 DOM 的入口点:

import react from 'react'
import reactDOM from 'react-dom'
import htm from 'htm'
import { BrowserRouter } from 'react-router-dom'
import { App } from './App.js'

const html = htm.bind(react.createElement)

reactDOM.render(
    html`<${BrowserRouter}><${App}/></>`,
    document.getElementById('root')
)

这段代码非常简单,因为我们主要导入 App 组件并将其附加到 DOM 中 ID 等于 root 的元素中。 唯一突出的细节是我们将应用程序包装到 BrowserRouter 组件中。 该组件来自 react-router-dom 库,它为我们的应用程序提供客户端路由功能。 我们接下来要编写的一些组件将展示如何充分利用这些路由功能以及如何使用链接将不同页面连接在一起。 稍后,我们将重新访问此路由配置,使其在服务器端也可用。

现在,让我们关注 App.js 的源代码:

import react from 'react'
import htm from 'htm'
import {Switch, Route} from 'react-router-dom'
import {AuthorsIndex} from './components/pages/AuthorsIndex.js'
import {Author} from './components/pages/Author.js'
import {FourOhFour} from './components/pages/FourOhFour.js'

const html = htm.bind(react.createElement)

export class App extends react.Component {
    render() {
        return html`
            <${Switch}>
                <${Route}
                        path="/"
                        exact=${true}
                        component=${AuthorsIndex}
                />
                <${Route}
                        path="/author/:authorId"
                        component=${Author}
                />
                <${Route}
                        path="*"
                        component=${FourOhFour}
                />
            </>
        `
    }
}

从这段代码中可以看出,App 组件负责加载所有页面组件并为其配置路由。

在这里,我们使用react-router-dom中的Switch组件。 该组件允许我们定义 Route 组件。 每个 Route 组件都需要有一个路径和一个与之关联的组件 prop。 在渲染时,Switch 将根据路由定义的路径检查当前 URL,并渲染与第一个匹配的 Route 组件关联的组件。

就像在 JavaScript switch 语句中一样,case 语句的顺序很重要,在这里,Route 组件的顺序也很重要。 我们的最后一条路线是一条包罗万象的路线,如果前面的路线均不匹配,则该路线将始终匹配。

另请注意,我们正在为第一条路线精确设置 prop。 这是必需的,因为react-router-dom将根据前缀进行匹配,因此普通的/将匹配任何URL。 通过指定exact: true,我们告诉路由器仅当该路径恰好是/时才匹配该路径(如果它只是以/开头则不匹配)。

现在让我们快速浏览一下 Header 组件:

import react from 'react'
import htm from 'htm'
import {Link} from 'react-router-dom'

const html = htm.bind(react.createElement)

export class Header extends react.Component {
    render() {
        return html`
            <header>
                <h1>
                    <${Link} to="/">My library</>
                </h1>
            </header>`
    }
}

这是一个非常简单的组件,仅呈现包含“我的库”的 h1 标题。 这里唯一值得讨论的细节是标题由来自react-router-dom库的Link组件包装。 该组件负责渲染一个可点击的链接,该链接可以与应用程序路由器交互以动态切换到新的路由,而无需刷新整个页面。

现在,我们必须一一编写我们的页面组件。 让我们从 AuthorsIndex 组件开始:

import react from 'react'
import htm from 'htm'
import {Link} from 'react-router-dom'
import {Header} from '../Header.js'
import {authors} from '../../../data/authors.js'

const html = htm.bind(react.createElement)

export class AuthorsIndex extends react.Component {
    render() {
        return html`
            <div>
                <${Header}/>
                <div>${authors.map((author) =>
                        html`
                            <div key=${author.id}>
                                <p>
                                    <${Link} to="${`/author/${author.id}`}">
                                    ${author.name}</>
                                </p>
                            </div>`)}
                </div>
            </div>`
    }
}

还有一个非常简单的组件。 在这里,我们根据数据文件中可用的作者列表动态呈现一些标记。 请注意,我们再次使用 react-router-dom 中的 Link 组件来创建到作者页面的动态链接。

现在,让我们看一下 Author 组件代码:

import react from 'react'
import htm from 'htm'
import {FourOhFour} from './FourOhFour.js'
import {Header} from '../Header.js'
import {authors} from '../../../data/authors.js'

const html = htm.bind(react.createElement)

export class Author extends react.Component {
    render() {
        const author = authors.find(
            author => author.id === this.props.match.params.authorId
        )
        if (!author) {
            return html`
                <${FourOhFour} error="Author not found"/>`
        }
        return html`
            <div>
                <${Header}/>
                <h2>${author.name}</h2>
                <p>${author.bio}</p>
                <h3>Books</h3>
                <ul>
                    ${author.books.map((book) =>
                            html`<li key=${book.id}>${book.title} (${book.year})</li>`
                    )}
                </ul>
            </div>`
    }
}

这个组件有一点逻辑。 在 render() 方法中,我们过滤作者数据集以查找当前作者。 请注意,我们使用 props.match.params.authorId 来获取当前作者 ID。 match 属性将在渲染时由路由器传递给组件,如果当前路径具有动态参数,则将填充嵌套的 params 对象。

记忆(nodejsdp.link/memoization)在 render() 方法中执行的任何复杂计算的结果是常见的做法。 这可以防止复杂计算再次运行,以防其输入自上次渲染以来未发生更改。 在我们的示例中,此类优化的可能目标是调用authors.find()。 我们将其留给您作为练习。 如果您想了解有关此技术的更多信息,请查看nodejsdp.link/react-memoization。

我们收到的 ID 可能与数据集中的任何作者都不匹配,因此在这种情况下,作者将是未定义的。 这显然是一个 404,因此我们没有渲染作者数据,而是将渲染逻辑委托给 FourOhFour 组件,该组件负责渲染 404 错误页面。

最后,我们看一下 FourOhFour 组件的源代码:

import react from 'react'
import htm from 'htm'
import {Link} from 'react-router-dom'
import {Header} from '../Header.js'

const html = htm.bind(react.createElement)

export class FourOhFour extends react.Component {
    render() {
        return html`
            <div>
                <${Header}/>
                <div>
                    <h2>404</h2>
                    <h3>${this.props.error || 'Page not found'}</h3>
                    <${Link} to="/">
                    Go back to the home page
                    </>
                </div>
            </div>`
    }
}

该组件负责渲染 404 页面。 请注意,我们通过 error 属性对错误消息进行了配置,并且我们使用了 React-router-dom 库中的链接,以允许用户在登陆此错误页面时返回主页。

这是相当多的代码,但我们终于准备好运行仅前端的 React 应用程序:只需在控制台中输入 npm start,您应该会看到该应用程序在浏览器中运行。 相当准系统,但如果我们正确地完成了所有操作,它应该按预期工作,并让我们看到我们最喜欢的作者和他们的杰作。

值得在打开浏览器开发人员工具的情况下使用该应用程序,以便我们可以验证动态路由是否正常工作,也就是说,加载第一个页面后,会在不刷新任何页面的情况下转换到其他页面。

为了更好地了解与 React 应用程序交互时发生的情况,您可以在 Chrome (nodejsdp.link/react-dev-tools-chrome) 或 Firefox (nodejsdp.link/reactdev-) 上安装并使用 React Developer Tools 浏览器扩展。 工具-火狐)。

服务端渲染

我们的应用程序可以运行,这是个好消息。 但是,该应用程序仅在客户端运行,这意味着如果我们尝试 curl 其中一个页面,我们将看到如下内容:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>My library</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="/main.js"></script></body>
</html>

没有任何内容! 只有一个空容器(根 div),这是我们的应用程序在运行时安装的位置。

在本节中,我们将修改我们的应用程序,以便也能够从服务器呈现内容。

让我们首先将 fastify 和 esm 添加到我们的项目中:

npm install --save fastify fastify-static esm

现在,我们可以在 src/server.js 中创建服务器应用程序:

import {resolve, dirname} from 'path'
import {fileURLToPath} from 'url'
import react from 'react'
import reactServer from 'react-dom/server.js'
import htm from 'htm'
import fastify from 'fastify'
import fastifyStatic from 'fastify-static'
import {StaticRouter} from 'react-router-dom'
import {App} from './frontend/App.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const html = htm.bind(react.createElement)
// (1)
const template = ({content}) => `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My library</title>
</head>
<body>
<div id="root">${content}</div>
<script type="text/javascript" src="/public/main.js"></script>
</body>
</html>`
const server = fastify({logger: true}) // (2)
server.register(fastifyStatic, { // (3)
    root: resolve(__dirname, '..', 'public'),
    prefix: '/public/'
})
server.get('*', async (req, reply) => { // (4)
    const location = req.raw.originalUrl
// (5)

    const serverApp = html`
        <${StaticRouter} location=${location}>
            <${App}/>
        </>
    `
    const content = reactServer.renderToString(serverApp) // (6)
    const responseHtml = template({content})
    reply.code(200).type('text/html').send(responseHtml)
})
const port = Number.parseInt(process.env.PORT) || 3000 // (7)
const address = process.env.ADDRESS || '127.0.0.1'
server.listen(port, address, function (err) {
    if (err) {
        console.error(err)
        process.exit(1)
    }
})

这里有很多代码,所以让我们逐步讨论这里介绍的主要概念:

  1. 由于我们不打算使用 webpack 开发服务器,因此我们需要从服务器返回页面的完整 HTML 代码。 在这里,我们使用函数和模板文字为所有页面定义 HTML 模板。 我们将把服务器渲染的 React 应用程序的结果作为内容传递给该模板,以获得最终的 HTML 返回给客户端。

  2. 在这里,我们创建一个 Fastify 服务器实例并启用日志记录。

  3. 您可能已经从我们的模板代码中注意到,我们的 Web 应用程序将加载脚本 /public/main.js。 该文件是由 webpack 生成的前端包。 在这里,我们让 Fastify 服务器实例使用 fastify-static 插件提供公共文件夹中的所有静态资源。

  4. 在这一行中,我们为每个发送到服务器的 GET 请求定义了一个包罗万象的路由。 我们之所以要做一个包罗万象的路由,是因为实际的路由逻辑已经包含在 React 应用程序中。 当我们渲染 React 应用程序时,它将根据当前 URL 显示正确的页面组件。

  5. 在服务器端,我们必须使用 react-router-dom 中的 StaticRouter 实例并用它包装我们的应用程序组件。 StaticRouter 是 React Router 的一个版本,可用于服务器端渲染。 该路由器允许我们通过 location 属性直接从服务器传递当前 URL,而不是从浏览器窗口获取当前 URL。

  6. 在这里,我们最终可以使用 React 的 renderToString() 函数为我们的 serverApp 组件生成 HTML 代码。 生成的 HTML 与客户端应用程序在给定 URL 上生成的 HTML 相同。 在接下来的几行中,我们使用 template() 函数将这段代码与页面布局包装在一起,最后将结果发送给客户端。

  7. 在最后几行代码中,我们告诉 Fastify 服务器实例侦听给定地址和端口(默认为 localhost:3000)。

现在,我们可以运行 npm run build 来创建前端包,最后,我们可以运行我们的服务器,如下所示:

node -r esm src/server.js

让我们在 http://localhost:3000/ 上打开浏览器,看看我们的应用程序是否仍按预期工作。 一切都很好,对吧? 伟大的! 现在,让我们尝试卷曲主页,看看服务器生成的代码是否看起来不同:

curl http://localhost:3000/

这一次,我们应该看到的是:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>My library</title>
</head>
<body>
<div id="root"><div><header><h1><a href="/">My library</a></h1></
    header><div><h2>Authors</h2><div><div><a href="/author/joyce"><p>James
        Joyce</p></a></div><div><a href="/author/h-g-wells"><p>Herbert George
        Wells</p></a></div><div><a href="/author/orwell"><p>George Orwell</p></
        a></div></div></div></div></div>
<script type="text/javascript" src="/public/main.js"></script>
</body>
</html>

太好了!这次,我们的根容器不是空的:我们直接从服务器渲染作者列表。你还应该试试一些作者页面,看看它们是否也能正常工作。任务完成!差不多吧…​…​如果我们尝试呈现一个不存在的页面,会发生什么情况呢?让我们来看看:

curl -i http://localhost:3000/blah

将会打印:

HTTP/1.1 200 OK
content-type: text/html
content-length: 367
Date: Sun, 05 Apr 2020 18:38:47 GMT
Connection: keep-alive

<!DOCTYPE html>
<html>
    <head>
    <meta charset="UTF-8">
        <title>My library</title>
    </head>
    <body>
    <div id="root"><div><header><h1><a href="/">My library</a></h1></
        header><div><h2>404</h2><h3>Page not found</h3><a href="/">Go back to
        the home page</a></div></div></div>
    <script type="text/javascript" src="/public/main.js"></script>
    </body>
</html>

乍一看,这似乎是正确的,因为我们正在渲染 404 页面,但实际上我们返回的是 200 状态代码……不好!

实际上,我们只需付出一点额外的努力就可以解决这个问题,所以让我们开始吧。

React StaticRouter 允许我们传递一个通用的 context prop,可用于在 React 应用程序和服务器之间交换信息。 我们可以利用这个实用程序来允许 404 页面将一些信息注入到这个共享上下文中,以便在服务器端我们知道是否应该返回 200 还是 404 状态代码。

让我们首先更新服务器端的 catch-all 路由:

server.get('*', async (req, reply) => {
    const location = req.raw.originalUrl
    const staticContext = {}
    const serverApp = html`
        <${StaticRouter}
                location=${location}
                context=${staticContext}
        >
            <${App}/>
        </>
    `
    const content = reactServer.renderToString(serverApp)
    const responseHtml = template({content})
    let code = 200
    if (staticContext.statusCode) {
        code = staticContext.statusCode
    }
    reply.code(code).type('text/html').send(responseHtml)
})

与先前版本相比的更改以粗体突出显示。 正如您所看到的,我们创建了一个名为 staticContext 的空对象,并将其传递给 context 属性中的路由器实例。 稍后,服务器端渲染完成后,我们检查在渲染过程中是否填充了 staticContext.statusCode。 如果是的话,它现在将包含我们必须返回给客户端的状态代码以及呈现的 HTML 代码。

现在让我们更改 FourOhFour 组件以实际填充该值。 为此,我们只需在返回要渲染的元素之前使用以下代码更新 render() 函数:

if (this.props.staticContext) {
    this.props.staticContext.statusCode = 404
}

请注意,传递给 StaticRouter 的 context 属性仅使用属性 staticContext 传递给 Route 组件的直接子级。 因此,如果我们重建前端包并重新启动服务器,这一次,我们将看到 http://localhost:3000/blah 的正确 404 状态,但它不适用于与作者页面匹配的 URL,例如 如 http://localhost:3000/author/blah

为了完成这项工作,我们还需要将 staticContext 从 Author 组件传播到 FourOhFour 组件。 为此,在 Author 组件的 render() 方法中,我们必须应用以下更改:

if (!author) {
    return html`<${FourOhFour}
        staticContext=${this.props.staticContext}
        error="Author not found"
    />`
}
// ...

现在,即使在不存在的作者的作者页面上,404 状态代码也将从服务器正确返回。

太棒了——我们现在有了一个功能齐全的 React 应用程序,它使用服务器端渲染! 但先别庆祝,我们还有一些工作要做……

异步数据检索

现在,想象一下,我们被要求为都柏林三一学院图书馆(世界上最著名的图书馆之一)建立网站。 已有约300年的历史,约有700万册图书。 好吧,现在让我们想象一下我们必须允许用户浏览这个海量的书籍集合。 是的,总共 700 万个……一个简单的数据文件在这里不是一个好主意!

更好的方法是使用专用 API 来检索有关书籍的数据,并使用它来动态获取呈现给定页面所需的最少量数据。 当用户浏览网站的各个页面时,将获取更多数据。

这种方法对于大多数 Web 应用程序都有效,因此让我们尝试将相同的原则应用于我们的演示应用程序。 我们将使用具有两个端点的 API:

  • /api/authors,获取作者列表

  • /api/author/:authorId,获取给定作者的信息

为了这个演示应用程序,我们将让事情变得非常简单。 我们只想演示一旦引入异步数据获取,我们的应用程序将如何改变,因此我们不会费心使用真实的数据库来支持我们的 API 或引入更高级的功能,如分页、过滤或搜索 。

由于利用我们现有的数据文件构建这样一个 API 服务器是一项相当简单的练习(在本章中不会增加太多价值),因此我们将跳过 API 实现的演练。 您可以从本书的代码仓库(nodejsdp.link/authors-api-server)获取API服务器的源代码。

这个简单的 API 服务器独立于我们的后端服务器运行,因此它使用另一个端口(甚至可能在另一个域上)。 为了允许浏览器向不同的端口或域发出异步 HTTP 请求,我们需要 API 服务器支持跨域资源共享或 CORS (nodejsdp.link/cors),这是一种允许安全跨域请求的机制。 值得庆幸的是,使用 Fastify 启用 CORS 就像安装 fastify-cors (nodejsdp.link/fastify-cors) 插件一样简单。

我们还需要一个能够在浏览器和 Node.js 上无缝运行的 HTTP 客户端。 一个不错的选择是 superagent (nodejsdp.link/superagent)。

然后让我们安装新的依赖项:

npm install --save fastify-cors superagent

现在我们已准备好运行 API 服务器:

node -r esm src/api.js

让我们用 curl 尝试一些请求,例如:

curl -i http://localhost:3001/api/authors
curl -i http://localhost:3001/api/author/joyce
curl -i http://localhost:3001/api/author/invalid

如果一切按预期进行,我们现在准备更新 React 组件以使用这些新的 API 端点,而不是直接从作者数据集中读取。 让我们从更新 AuthorsIndex 组件开始:

import react from 'react'
import htm from 'htm'
import {Link} from 'react-router-dom'
import superagent from 'superagent'
import {Header} from '../Header.js'

const html = htm.bind(react.createElement)

export class AuthorsIndex extends react.Component {
    constructor(props) {
        super(props)
        this.state = {
            authors: [],
            loading: true
        }
    }

    async componentDidMount() {
        const {body} = await superagent.get('http://localhost:3001/api/authors')
        this.setState({loading: false, authors: body})
    }

    render() {
        if (this.state.loading) {
            return html`
                <${Header}/>
                <div>Loading ...</div>`
        }
        return html`
            <div>
                <${Header}/>
                <div>${this.state.authors.map((author) =>
                        html`
                            <div key=${author.id}>
                                <p>
                                    <${Link} to="${`/author/${author.id}`}">
                                    ${author.name}</>
                                </p>
                            </div>`)}
                </div>
            </div>`
    }
}

与先前版本相比的主要更改以粗体突出显示。 本质上,我们将 React 组件转换为有状态组件。 在构造时,我们将状态初始化为空的作者数组,并将加载标志设置为 true。 然后,我们使用 componentDidMount 生命周期方法通过新的 API 端点加载作者数据。 最后,我们更新了 render() 方法,以在异步加载数据时显示加载消息。

现在,我们必须更新我们的 Author 组件:

import react from 'react'
import htm from 'htm'
import superagent from 'superagent'
import {FourOhFour} from './FourOhFour.js'
import {Header} from '../Header.js'

const html = htm.bind(react.createElement)

export class Author extends react.Component {
    constructor(props) {
        super(props)
        this.state = {
            author: null,
            loading: true
        }
    }

    async loadData() {
        let author = null
        this.setState({loading: false, author})
        try {
            const {body} = await superagent.get(
                `http://localhost:3001/api/author/${
                    this.props.match.params.authorId
                }`)
            author = body
        } catch (e) {
        }
        this.setState({loading: false, author})
    }

    componentDidMount() {
        this.loadData()
    }

    componentDidUpdate(prevProps) {
        if (prevProps.match.params.authorId !==
            this.props.match.params.authorId) {
            this.loadData()
        }
    }

    render() {
        if (this.state.loading) {
            return html`
                <${Header}/>
                <div>Loading ...</div>`
        }
        if (!this.state.author) {
            return html`
                <${FourOhFour}
                        staticContext=${this.props.staticContext}
                        error="Author not found"
                />`
        }
        return html`
            <div>
                <${Header}/>
                <h2>${this.state.author.name}</h2>
                <p>${this.state.author.bio}</p>
                <h3>Books</h3>
                <ul>
                    ${this.state.author.books.map((book) =>
                            html`
                                <li key=${book.id}>
                                    ${book.title} (${book.year})
                                </li>`
                    )}
                </ul>
            </div>`
    }
}

这里的更改与我们应用于前一个组件的更改非常相似。 在这个组件中,我们还将数据加载操作概括为 loadData() 方法。 我们这样做是因为该组件不仅实现了 componentDidMount(),还实现了 componentDidUpdate() 生命周期方法。 这是必要的,因为如果我们最终将新的 props 传递给同一个组件实例,我们希望组件能够正确更新。 例如,如果我们在作者页面中有一个指向另一个作者页面的链接,就会发生这种情况,如果我们在应用程序中实现 “相关作者” 功能,则可能会发生这种情况。

至此,我们已经准备好尝试这个新版本的代码了。 让我们使用 npm run build 重新生成前端包并启动后端服务器和 API 服务器,然后将浏览器指向 http://localhost:3000/。

如果您浏览各个页面,一切都应该按预期工作。 您可能还会注意到,当您浏览页面时,页面内容会以交互方式加载。

但是我们的服务器端渲染会发生什么情况呢? 如果我们尝试在主页上使用 curl,我们应该会看到返回以下 HTML 标记:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>My library</title>
</head>
<body>
<div id="root"><div><header><h1><a href="/">My library</a></h1></
    header><div>Loading ...</div></div></div>
<script type="text/javascript" src="/public/main.js"></script>
</body>
</html>

您是否注意到不再有任何内容,而只是一个毫无用处的 “正在加载……” 指示器? 不是很好。 此外,这并不是唯一的问题。 如果您尝试在无效的作者页面上使用 curl,您会注意到您将获得相同的 HTML 标记,带有加载指示器但没有内容,并且返回的状态代码是 200 而不是 404!

我们在服务器端渲染的标记上看不到任何真实内容,因为 componentDidMount 生命周期方法仅在浏览器上执行,而在服务器端渲染期间它会被 React 忽略。

此外,服务器端渲染是一个同步操作,因此即使我们将加载代码移到其他地方,在服务器上渲染时我们仍然无法执行任何异步数据加载。

在本章的下一节中,我们将探索一种可以帮助我们实现完全通用渲染和数据加载的模式。

通用数据检索

服务器端渲染是同步操作,这使得有效地预加载所有必要的数据变得困难。 能够避免我们在上一节末尾强调的问题并不像您想象的那么简单。

问题的根源在于我们将路由逻辑保留在 React 应用程序中,因此,在服务器上,我们无法知道在调用 renderToString() 之前我们实际上要渲染哪个页面。 这就是为什么服务器无法确定我们是否需要为特定页面预加载一些数据。

通用数据检索在 React 中仍然是一个相当模糊的领域,促进 React 服务器端渲染的不同框架或库针对这个问题提出了不同的解决方案。

截至今天,我们认为值得讨论的两种模式是两次渲染和异步页面。 这两种技术有不同的方式来确定哪些数据需要预加载。 在这两种情况下,一旦数据在服务器上完全加载,生成的 HTML 页面将提供一个内联脚本块,将所有数据注入到全局范围(窗口对象)中,以便当应用程序在浏览器上运行时,相同的 已经加载到服务器上的数据不必从客户端重新加载。

二次渲染

两遍渲染的思想是使用 React 路由器静态上下文作为向量在 React 和服务器之间交换信息。 图 10.4 向我们展示了它是如何工作的:

image 2024 05 07 19 18 33 361
Figure 2. 图 10.4:两遍渲染原理图

两遍渲染的步骤如下:

  1. 服务器调用 renderToString(),将从客户端接收到的 URL 和一个空的静态上下文对象传递给 React 应用程序。

  2. React 应用程序将执行路由过程并选择需要为给定 URL 呈现的组件。 每个需要异步加载某些数据的组件都需要实现一些额外的逻辑,以允许这些数据也预加载到服务器上。 这可以通过将表示数据加载操作结果的承诺附加到路由器静态上下文来完成。 这样,在渲染过程结束时,服务器将收到一个不完整的标记(表示当前加载状态),并且静态上下文将包含许多表示数据加载操作的承诺。

  3. 此时,服务器可以查看静态上下文并等待所有承诺解决,以确保所有数据已完全预加载。 在此过程中,服务器构建一个新的静态上下文,其中包含 Promise 返回的结果。 这个新的静态上下文用于第二轮渲染。 这就是为什么这种技术被称为二次渲染的原因。

  4. 现在,球再次位于场地的 React 一侧。 路由过程应该选择在第一次渲染过程中使用的相同组件,因为 URL 没有改变。 这次,需要数据预加载的组件应该看到这些数据已经在静态上下文中可用,并且可以立即渲染视图。 此步骤生成服务器现在可以使用的完整静态标记。

  5. 此时,服务器拥有完整的标记,并使用它来呈现最终的 HTML 页面。 服务器还可以将所有预加载的数据包含在脚本标记中,以便在浏览器上数据已经可用,因此在访问应用程序的第一页时无需再次加载它。

这项技术非常强大,并且有一些有趣的优点。 例如,它允许您以非常灵活的方式组织 React 组件树。 您可以有多个请求异步数据的组件,并且它们可以放置在组件树的任何级别。

在更高级的用例中,您还可以通过多个渲染通道加载数据。 例如,在第二遍期间,可能会呈现树中的一个新组件,并且该组件可能还需要异步加载数据,以便它可以向静态上下文添加新的承诺。 为了支持这种特殊情况,服务器必须继续渲染循环,直到静态上下文中不再有任何承诺为止。 两遍渲染技术的这种特殊变体被称为多遍渲染。

该技术的最大缺点是每次调用 renderToString() 都不便宜,并且在现实应用程序中,该技术可能会迫使服务器经历多个渲染过程,从而使整个过程非常缓慢。

这可能会导致整个应用程序的性能严重下降,从而极大地影响用户体验。

下一节将讨论一种更简单但可能性能更高的替代方案。

异步页面

我们将在这里描述的技术(我们将其称为 “异步页面”)基于 React 应用程序的更受约束的结构。

这个想法是以非常具体的方式构建应用程序组件树的顶层。 让我们先看一下可能的结构,然后再讨论这种特定方法如何帮助我们进行异步数据加载会更容易。

image 2024 05 07 19 20 44 172
Figure 3. 图 10.5:异步页面组件树结构

在图 10.5 中,我们展示了允许我们应用异步页面技术的结构。 让我们详细讨论组件树中每一层的范围:

  1. 应用程序的根始终是 Router 组件(服务器上的 StaticRouter 和客户端上的 BrowserRouter)。

  2. 应用程序组件是路由器组件的唯一子组件。

  3. 应用程序组件的唯一子组件是来自react-router-dom 包的Switch 组件。

  4. Switch 组件有一个或多个 Route 组件作为子组件。 这些用于定义所有可能的路线以及应为每个路线呈现哪个组件。

  5. 这是最有趣的层,因为我们实际上引入了“页面组件”的概念。 这个想法是页面组件负责整个页面的外观和感觉。 页面组件可以具有任意组件子树,用于呈现当前视图; 例如,页眉、正文和页脚。 我们可以有两种类型的页面组件:行为与任何其他 React 组件相同的常规页面组件和 AsyncPage 组件。 异步页面是特殊的有状态组件,需要预加载数据以便在服务器端和客户端上呈现页面。 它们实现了一个名为 preloadAsyncData() 的特殊静态方法,其中包含为给定页面预加载数据所需的逻辑。

可以看到,第 1 层到第 4 层负责路由逻辑,第 5 层负责数据加载和实际渲染当前页面。 没有其他嵌套层用于额外的路由和数据加载。

从技术上讲,第 5 级之后可能会有额外的路由和数据加载层,但这些层不会普遍可用,因为它们只能在页面呈现后在客户端进行解析。

现在我们已经讨论了这种更严格的结构,让我们看看它如何有助于避免多次渲染并实现通用数据检索。

想法是这样的:如果我们将路由定义在一个专用文件中作为路径和组件的数组,我们可以轻松地在服务器端重用该文件,并在 React 渲染阶段之前确定我们实际上最终将渲染哪个页面组件 。

然后,我们可以看看这个页面组件是否是一个AsyncPage。 如果是,则意味着我们必须在渲染之前在服务器端预加载一些数据。 我们可以通过从给定组件调用 preloadAsyncData() 方法来做到这一点。

一旦数据被预加载,就可以将其添加到静态上下文中,并且我们可以渲染整个应用程序。 在渲染阶段,AsyncPage 组件将看到其数据已预加载并在静态上下文中可用,并且它将能够立即渲染,跳过加载状态。

一旦渲染完成,服务器可以在脚本标签中添加相同的预加载数据,这样,在浏览器端,用户就不必等待数据再次加载。

Next.js 框架 (nodejsdp.link/nextjs) 是通用 JavaScript 应用程序的流行框架,并采用与此处描述的技术类似的技术,因此它是这种模式的一个很好的例子。

实现异步页面

现在我们知道如何解决数据获取问题,让我们在应用程序中实现异步页面技术。

我们的组件树的结构已经符合该技术的预期。 我们的页面是 AuthorsIndex 组件、Author 组件和 FourOhFour 组件。 前两个需要通用数据加载,因此我们必须将它们转换为异步页面。

让我们开始更新我们的应用程序,将路由定义推断到专用文件 src/frontend/routes.js 中:

import { AuthorsIndex } from './components/pages/AuthorsIndex.js'
import { Author } from './components/pages/Author.js'
import { FourOhFour } from './components/pages/FourOhFour.js'
export const routes = [
    {
        path: '/',
        exact: true,
        component: AuthorsIndex
    },
    {
        path: '/author/:authorId',
        component: Author
    },
    {
        path: '*',
        component: FourOhFour
    }
]

我们希望此配置文件成为应用程序各个部分的路由器配置的真实来源,因此让我们重构前端应用程序组件以也使用此文件:

// src/frontend/App.js
import react from 'react'
import htm from 'htm'
import {Switch, Route} from 'react-router-dom'
import {routes} from './routes.js'

const html = htm.bind(react.createElement)

export class App extends react.Component {
    render() {
        return html`
            <${Switch}>
                ${routes.map(routeConfig =>
                        html`
                            <${Route}
                                    key=${routeConfig.path}
                                    ...${routeConfig}
                            />`
                )}
            </>`
    }
}

正如您所看到的,这里唯一的变化是,我们不是内联定义各种路由组件,而是从路由配置数组开始动态构建它们。 routes.js 文件中的任何更改也将自动反映在应用程序中。

此时,我们可以更新 src/server.js 中的服务器端逻辑。

我们要做的第一件事是从 react-router-dom 包中导入一个实用函数,它允许我们查看给定的 URL 是否与给定的 React 路由器路径定义匹配。 我们还需要从新的routes.js 模块导入 routes 数组。

// ...
import { StaticRouter, matchPath } from 'react-router-dom'
import { routes } from './frontend/routes.js'
// ...

现在,让我们更新服务器端 HTML 模板生成功能,以便能够在页面中嵌入预加载的数据:

// ...
const template = ({content, serverData}) => `<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>My library</title>
    </head>
    <body>
        <div id="root">${content}</div>
        ${serverData ? `<script type="text/javascript">
        window.__STATIC_CONTEXT__=${JSON.stringify(serverData)}
        </script>` : ''}
        <script type="text/javascript" src="/public/main.js"></script>
    </body>
</html>`
// ...

正如您所看到的,我们的模板现在接受一个名为 serverData 的新参数。 如果将此参数传递给模板函数,它将呈现一个脚本标记,该标记会将这些数据注入到名为 window.__STATIC_CONTEXT__ 的全局变量中。

现在,让我们进入正题; 我们来重写服务端渲染逻辑:

// ...
server.get('*', async (req, reply) => {
    const location = req.raw.originalUrl
    let component // (1)
    let match
    for (const route of routes) {
        component = route.component
        match = matchPath(location, route)
        if (match) {
            break
        }
    }
    let staticData // (2)
    let staticError
    let hasStaticContext = false
    if (typeof component.preloadAsyncData === 'function') {
        hasStaticContext = true
        try {
            const data = await component.preloadAsyncData({match})
            staticData = data
        } catch (err) {
            staticError = err
        }
    }
    const staticContext = {
        [location]: {
            data: staticData,
            err: staticError
        }
    }
    // (3)
    const serverApp = html`
        <${StaticRouter}
                location=${location}
                context=${staticContext}
        >
            <${App}/>
        </>
    `
    const content = reactServer.renderToString(serverApp)
    const serverData = hasStaticContext ? staticContext : null
    const responseHtml = template({content, serverData})
    const code = staticContext.statusCode
        ? staticContext.statusCode
        : 200
    reply.code(code).type('text/html').send(responseHtml)
    // ...

这里有相当多的变化。 让我们逐一讨论主要块:

  1. 第一个更改旨在检测将为当前 URL 呈现哪个页面。 我们循环定义的路由,并使用 matchPath 实用程序来验证位置是否与当前路由定义匹配。 如果是,我们停止循环并在组件变量中记录将渲染哪个组件。 我们可以确定组件将在此处匹配,因为我们的最后一个路由(404 页面)将始终匹配。 匹配变量将包含有关匹配的信息。 例如,如果路由包含一些参数,则 match 将包含与每个参数匹配的路径片段。 例如,对于 URL /author/joyce,match 的属性参数等于 {authorId: 'joyce' }。 这与渲染时页面组件将从路由器接收到的属性相同。

  2. 在第二个更改块中,我们检查所选组件是否是 AsyncPage。 我们通过检查组件是否有一个名为 preloadAsyncData 的静态方法来做到这一点。 如果是这种情况,我们通过传递一个包含匹配对象的对象作为参数来调用该函数(这样,我们就可以传播获取数据可能需要的任何参数,例如authorId)。 这个函数应该返回一个承诺。 如果承诺解决,我们就成功地预加载了该组件的数据。 如果它拒绝,我们确保记录错误。 最后,我们创建 staticContext 对象。 该对象将预加载的数据(或拒绝错误)映射到当前位置。 我们将位置保留为键的原因是为了确保,如果出于任何原因,浏览器会从我们预加载的页面呈现另一个页面(由于程序错误或用户操作,例如点击上的后退按钮) 页面完全加载之前的浏览器),我们最终不会使用与浏览器上当前页面不相关的预加载数据。

  3. 在最后一个更改块中,我们调用 renderToString() 函数来获取应用程序的渲染 HTML。 请注意,由于我们传递包含预加载数据的静态上下文,因此我们希望应用程序能够完全呈现页面而无需返回加载状态视图。 当然,这不会神奇地发生。 我们需要向 React 组件添加一些逻辑,以检查静态上下文中是否已提供必要的数据。 生成 HTML 后,我们使用 template() 函数生成完整的页面标记并将其返回给浏览器。 我们还确保尊重状态代码。 例如,如果我们最终渲染了 FourOhFour 组件,我们将更改静态上下文中的 statusCode 属性,因此如果是这种情况,我们将使用该值作为最终状态代码; 否则,我们默认为 200。

这就是我们的服务器端渲染。

现在,是时候在我们的 React 应用程序中创建异步页面抽象了。 由于我们将有两个不同的异步页面,因此重用某些代码的一个好方法是创建一个基类并使用我们在第 9 章“行为设计模式”中已经讨论过的模板模式。 让我们在 src/frontend/components/pages/AsyncPage.js 中定义这个类:

import react from 'react'
export class AsyncPage extends react.Component {
    static async preloadAsyncData (props) { // (1)
        throw new Error('Must be implemented by sub class')
    }
    render () {
        throw new Error('Must be implemented by sub class')
    }
    constructor (props) { // (2)
        super(props)
        const location = props.match.url
        this.hasData = false
        let staticData
        let staticError
        const staticContext = typeof window !== 'undefined'
            ? window.__STATIC_CONTEXT__ // client-side
            : this.props.staticContext // server-side
        if (staticContext && staticContext[location]) {
            const { data, err } = staticContext[location]
            staticData = data
            staticError = err
            this.hasStaticData = true
            typeof window !== 'undefined' &&
            delete staticContext[location]
        }
        this.state = {
            ...staticData,
            staticError,
            loading: !this.hasStaticData
        }
    }
    async componentDidMount () { // (3)
        if (!this.hasStaticData) {
            let staticData
            let staticError
            try {
                const data = await this.constructor.preloadAsyncData(
                    this.props
                )
                staticData = data
            } catch (err) {
                staticError = err
            }
            this.setState({
                ...staticData,
                loading: false,
                staticError
            })
        }
    }
}

此类提供了用于构建有状态组件的帮助程序代码,该组件可以处理三种可能的情况:

  • 我们正在服务器上进行渲染,并且已经预加载了数据(无需加载数据)。

  • 我们正在客户端上进行渲染,并且数据已通过 __STATIC_CONTEXT__ 变量在页面中可用(无需加载数据)。

  • 我们正在客户端上呈现,并且数据不可用(例如,如果该页面不是由服务器呈现,而是用户在首次加载后导航到的页面)。 在这种情况下,当安装组件时,必须从客户端动态加载数据。

我们一起来回顾一下这个实现的要点:

  1. 该组件类不应该直接实例化,而只能在实现异步页面时进行扩展。 当扩展此类时,异步页面组件将需要实现方法 static async preloadAsyncData(props) 和 render()。

  2. 在构造函数中,我们必须初始化组件状态。 这里有两种可能的结果:数据已经可用(因此我们可以在状态中设置它)或数据不可用(因此我们需要将状态设置为 “正在加载” 并让组件在挂载后加载数据 在页面上)。 如果我们在浏览器上从静态上下文加载数据,我们还要确保从上下文中删除这些数据。 如果用户在导航期间碰巧返回此页面,这将允许用户看到新数据。

  3. componentDidMount() 方法仅在浏览器上由 React 执行。 在这里,我们处理数据未预加载的情况,我们必须在运行时动态加载它。

现在我们已经有了这个有用的抽象,我们可以重写 AuthorsIndex 和 Author 组件并将它们转换为异步页面。 让我们从 AuthorsIndex 开始:

import react from 'react'
import htm from 'htm'
import {Link} from 'react-router-dom'
import superagent from 'superagent'
import {AsyncPage} from './AsyncPage.js'
import {Header} from '../Header.js'

const html = htm.bind(react.createElement)

export class AuthorsIndex extends AsyncPage {
    static async preloadAsyncData(props) {
        const {body} = await superagent.get(
            'http://localhost:3001/api/authors'
        )
        return {authors: body}
    }

    render() {
        // unchanged...
    }
}

正如您在此处看到的,我们的 AuthorsIndex 组件现在扩展了 AsyncPage。 由于 AsyncPage 模板将在其构造函数中处理所有状态管理,因此我们不再需要此处的构造函数; 我们只需要在 preloadAsyncData() 方法中指定加载数据的业务逻辑即可。

如果将此实现与前一个实现进行比较,您可能会注意到该方法的逻辑与我们之前在 componentDidMount() 中的逻辑几乎相同。 方法 componentDidMount() 已从此处删除,因为我们从 AsyncPage 继承的方法就足够了。 之前版本的 componentDidMount() 和 preloadAsyncData() 唯一的区别是,在 preloadAsyncData() 中,我们不直接设置内部状态; 我们只需要返回数据。 AsyncPage 中的底层代码将根据我们的需要更新状态。

现在让我们重写 Author 组件:

import react from 'react'
import htm from 'htm'
import superagent from 'superagent'
import {AsyncPage} from './AsyncPage.js'
import {FourOhFour} from './FourOhFour.js'
import {Header} from '../Header.js'

const html = htm.bind(react.createElement)

export class Author extends AsyncPage {
    static async preloadAsyncData(props) {
        const {body} = await superagent.get(
            `http://localhost:3001/api/author/${
                props.match.params.authorId
            }`
        )
        return {author: body}
    }

    render() {
        // unchanged...
    }
}

这里的更改与我们对 AuthorsIndex 组件所做的更改完全一致。 我们只是将数据加载逻辑移至 preloadAsyncData() 中,并让底层抽象为我们管理状态转换。

现在,我们可以在 src/frontend/index.js 文件中应用最后一个小优化。 我们可以将reactDOM.render()函数调用与reactDOM.Hydrate()交换。 由于我们将从服务器端和客户端生成完全相同的标记,这将使 React 在第一次浏览器加载期间初始化速度更快一些。

我们终于准备好尝试所有这些改变了。 确保重建前端包并重新启动服务器。 查看应用程序和服务器生成的代码; 它应该包含每个页面的所有预加载数据。 此外,应该为每个 404 页面正确报告 404 错误,包括缺少作者的错误。

伟大的! 我们最终成功构建了一个在客户端和服务器之间有效共享代码、逻辑和数据的应用程序:一个真正的通用 JavaScript 应用程序!