对我们的下一本书感兴趣?了解更多关于使用 React 构建大规模 JavaScript Web 应用程序

渲染模式

流式服务器端渲染

通过流式服务器端渲染应用程序内容,我们可以减少交互时间,同时仍然进行服务器端渲染。我们可以将应用程序的必要标记分成更小的块,而不是生成一个包含当前导航所需标记的大型 HTML 文件!Node 流允许我们将数据流式传输到响应对象中,这意味着我们可以不断地将数据发送到客户端。客户端接收到数据块的那一刻,它就可以开始渲染内容。

React 自带的renderToNodeStream使我们能够以更小的块发送应用程序。由于客户端可以在仍在接收数据时开始绘制 UI,因此我们可以创建非常高效的首次加载体验。在接收到的 DOM 节点上调用hydrate方法将附加相应的事件处理程序,这使得 UI 具有交互性!

假设我们有一个应用程序,它在App组件中向用户展示数千个猫的趣事!

server.js
1import React from "react";
2import path from "path";
3import express from "express";
4import { renderToNodeStream } from "react-dom/server";
5
6import App from "./src/App";
7
8const app = express();
9
10// app.get("/favicon.ico", (req, res) => res.end());
11app.use("/client.js", (req, res) => res.redirect("/build/client.js"));
12
13const DELAY = 500;
14app.use((req, res, next) => {
15 setTimeout(() => {
16 next();
17 }, DELAY);
18});
19
20const BEFORE = `
21<!DOCTYPE html>
22 <html>
23 <head>
24 <title>Cat Facts</title>
25 <link rel="stylesheet" href="/style.css">
26 <script type="module" defer src="/build/client.js"></script>
27 </head>
28 <body>
29 <h1>Stream Rendered Cat Facts!</h1>
30 <div id="approot">
31`.replace(/
32s*/g, "");
33
34app.get("/", async (request, response) => {
35 try {
36 const stream = renderToNodeStream(<App />);
37 const start = Date.now();
38
39 stream.on("data", function handleData() {
40 console.log("Render Start: ", Date.now() - start);
41 stream.off("data", handleData);
42 response.useChunkedEncodingByDefault = true;
43 response.writeHead(200, {
44 "content-type": "text/html",
45 "content-transfer-encoding": "chunked",
46 "x-content-type-options": "nosniff"
47 });
48 response.write(BEFORE);
49 response.flushHeaders();
50 });
51 await new Promise((resolve, reject) => {
52 stream.on("error", err => {
53 stream.unpipe(response);
54 reject(err);
55 });
56 stream.on("end", () => {
57 console.log("Render End: ", Date.now() - start);
58 response.write("</div></body></html>");
59 response.end();
60 resolve();
61 });
62 stream.pipe(
63 response,
64 { end: false }
65 );
66 });
67 } catch (err) {
68 response.writeHead(500, {
69 "content-type": "text/pain"
70 });
71 response.end(String((err && err.stack) || err));
72 return;
73 }
74});
75
76app.use(express.static(path.resolve(__dirname, "src")));
77app.use("/build", express.static(path.resolve(__dirname, "build")));
78
79const listener = app.listen(process.env.PORT || 2048, () => {
80 console.log("Your app is listening on port " + listener.address().port);
81});

App组件使用内置的renderToNodeStream方法进行流式渲染。初始 HTML 以及来自 App 组件的数据块一起发送到响应对象中,

index.html
1<!DOCTYPE html>
2<html>
3 <head>
4 <title>Cat Facts</title>
5 <link rel="stylesheet" href="/style.css" />
6 <script type="module" defer src="/build/client.js"></script>
7 </head>
8 <body>
9 <h1>Stream Rendered Cat Facts!</h1>
10 <div id="approot"></div>
11 </body>
12</html>

这些数据包含我们的应用程序为了正确渲染内容而必须使用的一些有用信息,例如文档标题和样式表。如果我们使用renderToString方法服务器端渲染App组件,我们必须等到应用程序接收完所有数据才能开始加载和处理这些元数据。为了加快速度,renderToNodeStream 使应用程序能够在仍在接收来自 App 组件的数据块时开始加载和处理这些信息!

要查看有关如何实现渐进式水合和服务器端渲染的更多示例,请访问这个 GitHub 仓库

了解 styled-components 如何使用流式渲染来优化样式表的交付


概念

与渐进式水合一样,流式传输是另一种可用于提高 SSR 性能的渲染机制。顾名思义,流式传输意味着 HTML 块在生成时从节点服务器流式传输到客户端。由于客户端即使对于大型页面也能在早期开始接收 HTML“字节”,因此 TTFB 会降低并保持相对稳定。所有主要浏览器都开始更早地解析和渲染流式传输的内容或部分响应。由于渲染是渐进式的,因此 FP 和 FCP 也更低。

流式传输对网络背压反应良好。如果网络阻塞并且无法传输更多字节,渲染器将收到信号并停止流式传输,直到网络清理完毕。因此,服务器使用更少的内存,并且对 I/O 条件更具响应能力。这使您的 Node.js 服务器能够同时渲染多个请求,并防止较重的请求长时间阻塞较轻的请求。因此,即使在具有挑战性的条件下,网站也能保持响应能力。


用于流式传输的 React

React 在 2016 年发布的 React 16 中引入了对流式传输的支持。以下 API 包含在 ReactDOMServer 中以支持流式传输。

  1. ReactDOMServer.renderToNodeStream(element): 此函数的输出 HTML 与ReactDOMServer.renderToString(element)相同,但采用 Node.js 可读流格式,而不是字符串。该函数仅在服务器上运行以将 HTML 渲染为流。接收此流的客户端随后可以调用ReactDOM.hydrate() 来水合页面并使其具有交互性。

  2. ReactDOMServer.renderToStaticNodeStream(element): 这对应于ReactDOMServer.renderToStaticMarkup(element)。HTML 输出相同,但采用流格式。它可用于在服务器上渲染静态的、非交互式的页面,然后将它们流式传输到客户端。

这两个函数输出的可读流都可以在您开始从中读取时发出字节。这可以通过将可读流管道到可写流(例如响应对象)来实现。响应对象在等待渲染新块时,会逐渐将数据块发送到客户端。

将所有内容放在一起,让我们现在看一下这里发布的代码骨架这里

server.js
1import { renderToNodeStream } from 'react-dom/server';
2import Frontend from '../client';
3
4app.use('*', (request, response) => {
5 // Send the start of your HTML to the browser
6 response.write('<html><head><title>Page</title></head><body><div id="root">');
7
8 // Render your frontend to a stream and pipe it to the response
9 const stream = renderToNodeStream(<Frontend />);
10 stream.pipe(response, { end: 'false' });
11 // Tell the stream not to automatically end the response when the renderer finishes.
12
13 // When React finishes rendering send the rest of your HTML to the browser
14 stream.on('end', () => {
15 response.end('</div></body></html>');
16 });
17});

以下图像显示了正常 SSR 与流式传输的 TTFB 和首次有意义绘制之间的比较。

图像来源:https://mxstbr.com/thoughts/streaming-ssr/


流式传输 SSR - 优点和缺点

流式传输旨在提高 React 中 SSR 的速度,并提供以下好处

  1. 性能提升:由于第一个字节在服务器上开始渲染后很快到达客户端,因此 TTFB 比 SSR 更好。它也更加一致,与页面大小无关。由于客户端可以在收到 HTML 后立即开始解析,因此 FP 和 FCP 也更低。

  2. 处理背压:流式传输对网络背压或拥塞反应良好,即使在具有挑战性的条件下也能带来响应迅速的网站。

  3. 支持 SEO:搜索引擎爬虫可以读取流式传输的响应,从而实现网站的 SEO。

需要注意的是,流式传输实现并非简单地将renderToString替换为renderToNodeStream()。在某些情况下,与 SSR 一起使用的代码可能无法按原样与流式传输一起使用。以下是一些迁移可能并不容易的示例。

  1. 使用服务器端渲染传递来生成需要在 SSR 块之前添加到文档中的标记的框架。例如,在前面的<style>标记中动态确定要在页面中添加哪些 CSS 的框架,或者在渲染时将元素添加到文档<head>中的框架。这里讨论了解决此问题的变通方法这里
  2. 使用renderToStaticMarkup生成页面模板并嵌入renderToString调用的代码以生成动态内容。由于在这种情况下需要与组件对应的字符串,因此无法用流替换它。这里提供了一个这样的代码示例这里如下。
res.write("<!DOCTYPE html>");

res.write(renderToStaticMarkup(
 <html>
   <head>
     <title>My Page</title>
   </head>
   <body>
     <div id="content">
       { renderToString(<MyPage/>) }
     </div>
   </body>
 </html>);

流式传输和渐进式水合都可以帮助弥合纯 SSR 和 CSR 体验之间的差距。让我们现在比较一下我们已经探索的所有模式,并尝试了解它们对不同情况的适用性。