专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

SSR 的升级版:流式服务端渲染原理!

ins518 2024-10-06 10:29:58 技术文章 25 ℃ 0 评论

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

今天给大家带来的是服务端渲染中的流式渲染(刚好在patterns.dev中看到这个主题),子主题包括:性能指标介绍、什么是Stream、什么是流式渲染、流式渲染的方法、流式渲染的优缺点等等。话不多说,直接开始!

页面性能指标

开始进入流式渲染正题之前先了解下常见页面性能指标,这些指标会在后面章节陆续引入。

  • TTFB: 是一个衡量对资源的请求和响应的第一个字节开始和到达之间时间的指标。是Redirect time 重定向时延、Service worker 启动时延(如果适用)、DNS 查询时延、建立连接和 TLS 所消耗时延,直到响应的第一个字节到达为止的时延等的总和。
  • FMP:全称First Meaningful Paint ,为首次有效绘制。表示页面的”主要内容“开始出现在屏幕上的时间点,它是测量用户加载体验的主要指标
  • FCP:指标测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,"内容"指的是文本、图像(包括背景图像)、<svg>元素或非白色的<canvas>元素。
  • FP (First Paint) 首次绘制:标记浏览器渲染任何在视觉上不同于导航前屏幕内容之内容的时间点。

注意:更多体系指标可以阅读文末参考资料。

什么是NodeJS中的Stream

Stream 是支持 Node.js 应用程序的基本概念之一,是一种数据处理方法,用于依次读写数据的输入并输出。stream 是一种有效地处理读/写文件、网络通信或任何类型的端到端信息交换的方法。stream 的独特之处在于,它不像传统程序那样一次性将文件读入内存,而是逐块读取数据块。

这使得 stream 在处理大量数据时非常强大。例如,文件大小可能大于空闲内存空间,因此不可能将整个文件读取到内存中以进行处理。这就是 stream 的用武之地。

以 Youtube 或 Netflix 等 ”流媒体“ 服务提供商为例:它们不会让用户一次性下载整个视频和音频。相反,浏览器会接收到连续不断的视频片段。然而,stream 不仅仅与媒体或大数据打交道。它在服务端渲染领域也是大有用武之地,这就是下文要重点讲述的流式服务端渲染。

流式服务端渲染(Streaming Server-Side Rendering)

通过流式服务端渲染应用界面,可以显著加快用户交互时间(Time To Interactive)。 流式服务端渲染将应用拆分成更小的块,而不是生成一个包含当前导航所标记的大型 HTML 文件。 Node 的流(Node Stream)允许开发者将数据流式传输到响应对象中,这意味着开发者可以不间断的将数据持续发送到客户端。 客户端从收到数据块的那一刻,就可以开始渲染内容。

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

假设有一个应用程序,在 App 组件中向用户显示大量的图片,可以如下编码:

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();
// 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>流式渲染示例</title>
      <link rel="stylesheet" href="/style.css">
      <script type="module" defer src="/build/client.js"></script>
    </head>
    <body>
      <h1>流式渲染内容</h1>
      <div id="approot">
`.replace(/
s*/g, "");
// 应用请求
app.get("/", async (request, response) => {
  try {
    const stream = renderToNodeStream(<App />);
    const start = Date.now();
    // 获取到data数据会触发
    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();
      });
      // 调用pipe方法发送数据
      stream.pipe(
        response,
        { end: false }
      );
    });
  } catch (err) {
    //   异常处理
    response.writeHead(500, {
      "content-type": "text/pain"
    });
    response.end(String((err && err.stack) || err));
    return;
  }
});
// use调用中间件
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);
});

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

<!DOCTYPE html>
<html>
  <head>
    <title>流式渲染示例</title>
    <link rel="stylesheet" href="/style.css" />
    <script type="module" defer src="/build/client.js"></script>
  </head>
  <body>
    <h1>流式渲染!</h1>
    <div id="approot"></div>
  </body>
</html>

上面的 HTML 包含了为正确渲染应用而必须包含的信息,例如:文档标题和样式表。 如果使用 renderToString 方法在服务器上渲染 App 组件,将不得不等到应用程序收到所有渲染数据后才能开始加载和处理数据。而 renderToNodeStream 使得整个过程能够并行进行,从而极大提升渲染效率。

下面是renderToString方法的使用示例:

import { renderToString } from 'react-dom/server';
// The route handler syntax depends on your backend framework
app.use('/', (request, response) => {
  const html = renderToString(<App />);
  response.send(html);
});

在客户端,renderToString需要调用 hydrateRoot 使服务器生成的 HTML 具有交互性。

const root = hydrateRoot(domNode, reactNode, options?)
//hydrateRoot 允许在浏览器 DOM 节点内显示 React 组件,其 HTML 内容
// 先前由 react-dom/server 生成。

流式服务端渲染的网络背压

与渐进水合(Hydration)一样,流式传输是另一种可用于提高 SSR 性能的渲染机制。 顾名思义,流意味着 HTML 块在生成时从 Node 服务器流式传输到客户端。 由于客户端更早地开始接收“字节(bytes)”的 HTML,即使对于大页面也是如此,TTFB 会减少并且相对稳定。 所有主流浏览器都会更早地开始解析、渲染流式内容。 由于渲染是渐进式的,因此会产生快速的 FP 和 FCP。

流对网络背压(Network Backpressure)反应良好。 如果网络阻塞并且无法传输更多字节,渲染器会收到信号并停止流式传输,直到网络正常。 因此,流式渲染服务器使用的内存更少,对 I/O 的响应更快。 这使 Node.js 服务器能够同时渲染多个请求,并防止较重的请求长时间阻塞较轻的请求,从而提升应用响应性。

流式服务端渲染与 React

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

  • ReactDOMServer.renderToNodeStream(element):此函数的输出 HTML 与 ReactDOMServer.renderToString(element) 相同,但采用 Node.js 可读流格式而不是字符串。 该函数仅在服务器上工作以将 HTML 渲染为流。 接收此流的客户端随后可以调用 ReactDOM.hydrate() 来水合页面并使其具有交互性。
  • ReactDOMServer.renderToStaticNodeStream(element):对应于 ReactDOMServer.renderToStaticMarkup(element)。 HTML 输出是相同的,但采用流格式。 它可用于在服务器上渲染静态、非交互式页面,然后将它们流式传输到客户端。

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

比如下面的代码示例:

import { renderToNodeStream } from 'react-dom/server';
import Frontend from '../client';
app.use('*', (request, response) => {
  // 将 HTML 的开头发送到浏览器
  response.write('<html><head><title>页面</title></head><body><div id="root">');
  // 将前端渲染为流并将其通过管道传递给响应
  const stream = renderToNodeStream(<Frontend />);
  stream.pipe(response, { end: 'false' });
  //告诉流不要在渲染器完成时自动结束响应。
  // 当 React 完成渲染时,将剩余的 HTML 发送到浏览器
  stream.on('end', () => {
    response.end('</div></body></html>');
  });
});

下图提供了普通 SSR 与流式服务端渲染的 TTFB 和 First Meaningful Paint 之间的比较。

流式 SSR - 优点和缺点

Streaming与React的组合是为了提高 SSR 的渲染速度,该方案由以下明显好处:

  • 性能改进:由于在服务器上开始渲染后第一个字节很快到达客户端,因此 TTFB 优于 SSR,而且不受页面大小影响。 由于客户端一收到 HTML 就可以开始解析,所以 FP 和 FCP 也比较低。
  • 背压处理:流对网络背压或拥塞反应良好,即使网络条件恶劣也能响应迅速。
  • 支持搜索引擎优化:流式响应可以被搜索引擎爬虫读取,从而允许在网站上进行搜索引擎优化。

需要注意的是,流式实现不是从 renderToString 到 renderToNodeStream() 的简单替换。 在某些情况下,适用于 SSR 的代码可能无法按原样适用于流式传输。 以下是从 renderToString 切换到 renderToNodeStream 可能遇到的诸多问题:

  • 使用流式服务端渲染的框架如果要将内容添加到 SSR 的某一个 chunk 之前的 document 中。典型示例是:框架想要动态确定将那些 CSS 添加到某一个<style> 标签,或者框架想要在渲染时将某一个元素添加到文档的标签中
  • 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的升级版本,即流式服务端渲染。因为篇幅有限,文章并没有过多展开,如果有兴趣,可以在我的主页继续阅读,同时文末的参考资料提供了优秀文档以供学习。最后,欢迎大家点赞、评论、转发、收藏!


参考资料

https://www.patterns.dev/posts/streaming-ssr

https://web.dev/i18n/zh/ttfb/

https://beta.reactjs.org/reference/react-dom/client/hydrateRoot

https://pawelgrzybek.com/understanding-nodejs-streams/

https://juejin.cn/post/6945280047662465037

https://songyazhao.github.io/2020/11/02/Web%20Performance%E5%B8%B8%E8%A7%81%E6%80%A7%E8%83%BD%E6%8C%87%E6%A0%87/

https://web.dev/i18n/zh/fcp/

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表