流(媒体)式服务器端渲染 ✔

我们可以通过流式服务器渲染我们的应用程序的内容来减少服务器渲染我们的应用程序的时间。
我们可以将其拆分为更小的块,而不是生成包含当前导航所需标记的大型 HTML 文件!
节点流允许我们将数据放入响应对象中,这意味着我们可以不断地将数据向下发送到客户端。
客户端收到数据块的那一刻,它就可以开始呈现内容。

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

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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116

// src/App.js
import React from "react";
import facts from "../data";

const App = () =>
  facts.map((fact, i) => (
    <div className="card">
      <p key={i}>{fact.fact}</p>
    </div>
  ));

export default App;

// src/client.js
import React from "react";
import { hydrate } from "react-dom";
import App from "./App";

hydrate(<App />, document.getElementById("root"));

// src/server.js
import React from "react";
import path from "path";
import express from "express";
import { renderToNodeStream } from "react-dom/server";

import App from "./src/App";

const app = express();

app.get("/favicon.ico", (req, res) => res.end());
app.use("/client.js", (req, res) => res.redirect("/build/client.js"));

const DELAY = 500;
app.use((req, res, next) => {
  setTimeout(() => {
    next();
  }, DELAY);
});

const BEFORE = `
<!DOCTYPE html>
  <html>
    <head>
      <title>Cat Facts</title>
      <link rel="stylesheet" href="/style.css">
      <script type="module" defer src="/build/client.js"></script>
    </head>
    <body>
      <h1>Stream Rendered Cat Facts!</h1>
      <div id="approot">
`.replace(/\n\s*/g, "");

app.get("/", async (request, response) => {
  try {
    const stream = renderToNodeStream(<App />);
    const start = Date.now();

    stream.on("data", function handleData() {
      console.log("Render Start: ", Date.now() - start);
      stream.off("data", handleData);
      response.useChunkedEncodingByDefault = true;
      response.writeHead(200, {
        "content-type": "text/html",
        "content-transfer-encoding": "chunked",
        "x-content-type-options": "nosniff"
      });
      response.write(BEFORE);
      response.flushHeaders();
    });
    await new Promise((resolve, reject) => {
      stream.on("error", err => {
        stream.unpipe(response);
        reject(err);
      });
      stream.on("end", () => {
        console.log("Render End: ", Date.now() - start);
        response.write("</div></body></html>");
        response.end();
        resolve();
      });
      stream.pipe(
        response,
        { end: false }
      );
    });
  } catch (err) {
    response.writeHead(500, {
      "content-type": "text/pain"
    });
    response.end(String((err && err.stack) || err));
    return;
  }
});

app.use(express.static(path.resolve(__dirname, "src")));
app.use("/build", express.static(path.resolve(__dirname, "build")));

const listener = app.listen(process.env.PORT || 2048, () => {
  console.log("Your app is listening on port " + listener.address().port);
});

// data.js
export default [
  {
    fact:
      "Tests done by the Behavioral Department of the Musuem of Natural History conclude that while a dog's memory lasts about 5 minutes, a cat's recall can last as long as 16 hours.",
    length: 175
  },
  {
    fact:
      "Relative to its body size, the clouded leopard has the biggest canines of all animals 2019 canines. Its dagger-like teeth can be as long as 1.8 inches (4.5 cm).",
    length: 156
  }
]

App 组件使用内置的 renderToNodeStream 方法获取流渲染。
初始 HTML 与来自 App 组件的数据块一起发送到响应对象,

此数据包含我们的应用程序必须使用的有用信息才能正确呈现内容,例如文档的标题和样式表。

如果我们使用 renderToString 方法服务器渲染 App 组件,我们将不得不等到应用程序接收到所有数据才能开始加载和处理这些元数据。
为了加快速度,renderToNodeStream 使应用程序可以开始加载和处理此信息,因为它仍在接收来自应用程序组件的数据块!

要查看有关如何实施渐进式水化和服务器渲染的更多示例,请访问此 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 输出是相同的,但采用流格式。
    它可用于在服务器上呈现静态、非交互式页面,然后将它们流式传输到客户端。

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

综上所述,现在让我们看看这里发布的代码框架。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

// App.js
import React from "react";
import facts from "../data";

const App = () =>
  facts.map((fact, i) => (
    <div className="card">
      <p key={i}>{fact.fact}</p>
    </div>
  ));

export default App;

// src/client.js
import React from "react";
import { hydrate } from "react-dom";
import App from "./App";

hydrate(<App />, document.getElementById("root"));

// src/server.js
import React from "react";
import path from "path";
import express from "express";
import { renderToNodeStream } from "react-dom/server";

import App from "./src/App";

const app = express();

app.get("/favicon.ico", (req, res) => res.end());
app.use("/client.js", (req, res) => res.redirect("/build/client.js"));

const DELAY = 500;
app.use((req, res, next) => {
  setTimeout(() => {
    next();
  }, DELAY);
});

const BEFORE = `
<!DOCTYPE html>
  <html>
    <head>
      <title>Cat Facts</title>
      <link rel="stylesheet" href="/style.css">
      <script type="module" defer src="/build/client.js"></script>
    </head>
    <body>
      <h1>Stream Rendered Cat Facts!</h1>
      <div id="approot">
`.replace(/\n\s*/g, "");

app.get("/", async (request, response) => {
  try {
    const stream = renderToNodeStream(<App />);
    const start = Date.now();

    stream.on("data", function handleData() {
      console.log("Render Start: ", Date.now() - start);
      stream.off("data", handleData);
      response.useChunkedEncodingByDefault = true;
      response.writeHead(200, {
        "content-type": "text/html",
        "content-transfer-encoding": "chunked",
        "x-content-type-options": "nosniff"
      });
      response.write(BEFORE);
      response.flushHeaders();
    });
    await new Promise((resolve, reject) => {
      stream.on("error", err => {
        stream.unpipe(response);
        reject(err);
      });
      stream.on("end", () => {
        console.log("Render End: ", Date.now() - start);
        response.write("</div></body></html>");
        response.end();
        resolve();
      });
      stream.pipe(
        response,
        { end: false }
      );
    });
  } catch (err) {
    response.writeHead(500, {
      "content-type": "text/pain"
    });
    response.end(String((err && err.stack) || err));
    return;
  }
});

app.use(express.static(path.resolve(__dirname, "src")));
app.use("/build", express.static(path.resolve(__dirname, "build")));

const listener = app.listen(process.env.PORT || 2048, () => {
  console.log("Your app is listening on port " + listener.address().port);
});


// data.js
export default [
  {
    fact:
      "Tests done by the Behavioral Department of the Musuem of Natural History conclude that while a dog's memory lasts about 5 minutes, a cat's recall can last as long as 16 hours.",
    length: 175
  },
  {
    fact:
      "Relative to its body size, the clouded leopard has the biggest canines of all animals 2019 canines. Its dagger-like teeth can be as long as 1.8 inches (4.5 cm).",
    length: 156
  }
]

下图提供了普通 SSR 与流媒体的 TTFB 和第一次有意义的绘制之间的比较。


流式 SSR - 优点和缺点

Streaming 旨在通过 React 提高 SSR 的速度并提供以下好处

  1. 性能提升:

由于服务端开始渲染后第一个字节很快到达客户端,TTFB优于SSR。
无论页面大小如何,它也更加一致。 由于客户端一接收到 HTML 就可以开始解析,所以 FP 和 FCP 也较低。

  1. 处理背压:

流媒体对网络背压或拥塞反应良好,即使在具有挑战性的条件下也能产生响应式网站。

  1. 支持 SEO:流响应可以被搜索引擎爬虫读取,从而允许在网站上进行 SEO。

需要注意的是,流式实现不是从 renderToString 到 renderToNodeStream() 的简单查找替换。
在某些情况下,适用于 SSR 的代码可能无法按原样适用于流媒体。 以下是迁移可能并不容易。

  1. 使用 server-render-pass 生成标记的框架,这些标记需要在 SSR-ed 块之前添加到文档中。
    示例是动态确定在前面的 <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>);

Streaming 和 Progressive Hydration 都可以帮助弥合纯 SSR 和 CSR 体验之间的差距。 现在让我们比较我们探索过的所有模式,并尝试了解它们对不同情况的适用性。


知识点