React Streaming SSR 原理解析

本文详细介绍了React 18中的Streaming SSR技术,包括功能简介、基本原理、源码解析。Streaming SSR能够分段传输HTML,提高页面加载速度,并支持Selective Hydration,使页面更快获得交互性。文章通过示例解释了Streaming HTML和Selective Hydration的工作机制,并深入源码探讨了Segment、Boundary、Task和Request等核心概念。
摘要由CSDN通过智能技术生成

作者:徐超

功能简介

React 18 提供了一种新的 SSR 渲染模式: Streaming SSR。通过 Streaming SSR,我们可以实现以下两个功能:

  • Streaming HTML:服务端可以分段传输 HTML 到浏览器,而不是像 React 18 以前一样,需要等待服务端渲染完成整个页面后才返回给浏览器。这样,浏览器可以更快的启动 HTML 的渲染,提高 FP、FCP 等性能指标。
  • Selective Hydration:在浏览器端 hydration 阶段,可以只对已经完成渲染的区域做 hydration,而不需要等待整个页面渲染完成、所有组件的 JS  bundle 加载完成,才能开始 hydration。这样可以更早的对已经完成渲染的区域做事件绑定,从而让页面获得更好的可交互性。

基本原理

使用示例

React 官网给出的一个简单的使用示例(以 Node.js 环境下的 API 为例)如下:

let didError = false;
const stream = renderToPipeableStream(
  <App />,
  { 
    bootstrapScripts: ["main.js"],
    onShellReady() {
      // The content above all Suspense boundaries is ready.
      // If something errored before we started streaming, 
      // we set the error code appropriately.
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-type', 'text/html');
      stream.pipe(res);
    },
    onShellError(error) {
      // Something errored before we could complete the shell 
      // so we emit an alternative shell.
      res.statusCode = 500;
      res.send('<!doctype html><p>Loading...</p><script src="clientrender.js"></script>');
    },
    onAllReady() {
      // stream.pipe(res);
    },
    onError(err) {
      didError = true;
      console.error(err);
    }
  }
);

renderToPipeableStream 是在 Node.js 环境下实现 Streaming SSR 的 API。

Streaming HTML

HTTP 支持以 stream 格式进行数据传输。当 HTTP 的 Response header 设置 Transfer-Encoding: chunked 时,服务器端就可以将 Response 分段返回。一个简单示例:

const http = require("http");
const url = require("url");

const sleep = (ms) => {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
};

const server = http.createServer(async (req, res) => {
  const { pathname } = url.parse(req.url);
  if (pathname === "/") {
    res.statusCode = 200;
    res.setHeader("Content-Type", "text/html");
    res.setHeader("Transfer-Encoding", "chunked");
    res.write("<html><body><div>First segment</div>");
    // 手动设置延时,让分段显示的效果更加明显
    await sleep(2000);
    res.write("<div>Second segment</div></body></html>");
    res.end();
    return;
  }

  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("okay");
});

server.listen(8080);

当访问 localhost:8080 时,「First segment」 和 「Second segment」会分 2 次传输到浏览器端,「First segment」先显示到页面上,2s 延迟后,「Second segment」再显示到页面上。

React 中的 Streaming HTML 要更加复杂。例如,对下面的 App 组件做 SSR:

//文件1: Content.js
export default function Content() {
  return (
    <div> This is content </div>
  );
}

// 文件2:App.js
import { Suspense, lazy } from "react";

const Content = lazy(() => import("./Content"));

export default function App() {
  return (
    <html>
      <head></head>
      <body>
        <div>App shell</div>
        <Suspense>
          <Content />
        </Suspense>
      </body>
    </html>
  );
}

第 1 次访问页面时,SSR 渲染的结果会分成 2 段传输,传输的第 1 段数据,经过格式化后,如下:

<!DOCTYPE html>
<html>
   <head></head>
   <body>
      <div>App shell</div>
      <!--$?-->
      <template id="B:0"></template>
      <!--/$-->
   </body>
</html>

其中 template 标签的用途是为后续传输的 Suspense 的 children 渲染结果占位,注释   和   中间的内容,表示是异步渲染出来的。

传输的第 2 段数据,经过格式化后,如下:

<div hidden id="S:0"> 
  <div> This is content </div>
</div>
<script> 
  function $RC(a, b) {
    a = document.getElementById(a);
    b = document.getElementById(b);
    b.parentNode.removeChild(b);
    if (a) {
        a = a.previousSibling;
        var f = a.parentNode,
            c = a.nextSibling,
            e = 0;
        do {
            if (c && 8 === c.nodeType) {
                var d = c.data;
                if ("/$" === d)
                    if (0 === e) break;
                    else e--;
                else "$" !== d && "$?" !== d && "$!" !== d || e++
            }
            d = c.nextSibling;
            f.removeChild(c);
            c = d
        } while (c);
        for (; b.firstChild;) f.insertBefore(b.firstChild, c);
        a.data = "$";
        a._reactRetry && a._reactRetry()
    }
  };
  $RC("B:0", "S:0") 
</script>

id=“S:0” 的 div 正是 Suspense 的 children 的渲染结果,但是这个 div 设置了 hidden 属性。接下来的 $RC 函数,会负责将这个 div 插入到第 1 段数据中 template 标签所在的位置,同时删除 template 标签。

总结一下 React Streaming SSR ,会先传输所有  以上层级的可以同步渲染得到的 html 结构,当  内的组件渲染完成后,会把这部分组件对应的渲染结果,连同一个 JS 函数再传输到浏览器端,这个 JS 函数会更新 dom ,得到最终的完整 HTML 结构。

当第 2 次访问页面时,html 结构会一次性返回,而不会分成 2 次传输。这时候  组件为什么没有将传输的数据分段呢?这是因为第 1 次请求时, Content 组件对应的 JS 模块在服务器端已经被加载到模块缓存中,再次请求时,加载 Content组件是一个同步过程,所以整个渲染过程是同步的,不存在分段传输渲染结果的情况。由此可见,只有当 的 children,需要被异步渲染时,SSR 返回的 HTML 才会被分段传输。

除了动态加载 JS 模块(code splitting)会产生分段传输数据的效果外,组件内获取异步数据则是更加常见的适用 Streaming SSR 的场景。

我们将 Content 组件做改造,通过调用异步函数 getData 获取数据:

let data;
const getData = () => {
  if (!data) {
    data = new Promise((resolve) => {
      // 延迟 2s 返回数据
      setTimeout(() => {
        data = "content from remote";
        resolve();
      }, 2000);
    });
    throw data;
  }

  // promise-like
  if (data && data.then) {
    throw data;
  }

  const result = data;
  data = undefined;
  return result;
};

export default function Content() {
  // 获取异步数据
  const data = getData();
  
  return <div>{data}</div>;
}

这样,Content 的内容会延迟 2s,待获取到 data 数据后传输到浏览器显示。示例代码(codesandbox 最近升级了,在 html 的 head 里注入了会阻塞 DOM 渲染的 JS,导致 Streaming SSR 效果可能失效,可以把代码复制到本地测试)。

注意:在数据未准备好前,getData 必须 throw 一个 promise,promise 会被 Suspense 组件捕获,这样才能保证 Streaming SSR 的顺利执行。

Selective Hydration

React 18 之前,SSR 实际上是不支持 code splitting 的,只能使用一些 workaround,常见的方式有:1. 对于需要 code splitting 的组件,不在服务端渲染,而是在浏览器端渲染;2. 提前将 code splitting 的 JS 写到 html script 标签中,在客户端等待所有的 JS 加载完成后再执行 hydration。

这一点 React Team 的 Dan 在 Suspense 的 RFC 中也有提及:

To the best of our knowledge, even popular workarounds forced you to choose between either opting out of SSR for code-split components or hydrating them after all their code loads, somewhat defeating the purpose of code splitting.

当前 Modern.js 对于这种情况的处理,采用的是第 2 种方式。Modern.js 利用 @loadable/component 在 SSR 阶段,收集做了 code splitting 的组件的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值