React Streaming SSR原理示例深入解析

功能简介

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(
  ,
  { 
    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('

Loading...

'); }, 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("
First segment
"); // 手动设置延时,让分段显示的效果更加明显 await sleep(2000); res.write("
Second segment
"); 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 SSR原理示例深入解析_第1张图片

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

//文件1: Content.js
export default function Content() {
  return (
    
This is content
); } // 文件2:App.js import { Suspense, lazy } from "react"; const Content = lazy(() => import("./Content")); export default function App() { return (
App shell
); }

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



   
   
      
App shell

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

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



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 
{data}
; }

这样,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 的组件的 JS bundle,然后把这些 JS bundle 添加到 html script 标签中,@loadable/component 提供了一个 API loadableReady ,在等待 JS bundle 加载完成后,才执行 hydration 。示意代码如下:

loadableReady(function(){
  hydrateRoot(root, )
})

如果在没有等待所有的 JS bundle 都加载完成,就开始 hydration,会出现什么问题呢?

考虑下面的例子,Content 组件做了 code splitting,如果在浏览端,在 Content 组件的 JS bundle 还未加载完成时,就开始 hydration,hydration 得到的 HTML 结构将缺少 Content 组件的内容,而服务端 SSR 返回的结构则是包含 Content 组件的,导致如下报错:

Hydration failed because the initial UI does not match what was rendered on the server.

import loadable from '@loadable/component'
const Content = loadable(() => import("./Content"));
export default function App() {
  return (
    
      
      
        
App shell
); }

把上面的代码,用 React 18 的 lazy 和 Suspense 改写,就可以支持 Selective Hydration,使得 SSR 真正支持 code splitting:

import {lazy, Suspense} from 'react'
const Content = lazy(() => import("./Content"));
export default function App() {
  return (
    
      
      
        
App shell
); }

如果 Content 组件的 JS bundle 还没有加载完成,在 hydration 阶段,渲染到 Suspense 节点时会跳出,而不会让整个 hydration 过程失败。

Selective Hydration 还有另外一种使用场景:同步导入 Content 组件(不做 code splitting),但是需要注意 Content 组件内仍然有异步的读取数据操作(见上文代码),另外增加一个 SideBar 组件,用于验证事件绑定,代码如下:

import {lazy, Suspense, useState} from 'react'
// 同步导入 Content 组件
import Content from './Content';
const Sidebar = () => {
  const [color, setColor] = useState('black');
  return (
    
Siderbar
); }; export default function App() { return (
App shell
); }

访问页面时,在渲染出 Content 组件前,Siderbar 就已经可以交互了(点击 change 按钮,文字颜色会改变)。说明,虽然所有组件使用一个 JS bundle 做 hydration,但是如果 Suspense 内的组件没有完成渲染,并不会影响其他已经渲染出的组件做 hydration。示例代码。

总结一 下,React 18 的 hydration 阶段,当渲染到 Suspense 组件时,会根据 Suspense 的 children 是否已经渲染完成,而选择是否继续向子组件执行 hydration。未渲染完成的组件待渲染完成后,会恢复执行 hydration。 Suspense 的 children 异步渲染的两种场景:1. children 组件做了 code splitting;2. children 组件中有异步操作。

降级逻辑

Streaming SSR 过程中,如果某个 Suspense 的 children 渲染过程抛出异常,那么这个 children 组件将降级到 CSR,即在浏览器端重新尝试渲染。

例如,我们对前面使用的 Content 组件做改造,刻意在服务端 SSR 阶段抛出异常:

export default function Content() {
  const _data = getData();
  // 制造异常
  if(typeof window === 'undefined'){
    data = undefined
    throw Error('SSR Error')
  }
  return (
    
{_data}
); }

访问页面时,Response 返回的第二段数据,格式化后如下所示:


第二段数据中返回了 RX 函数,而不是渲染正确情况下的 RX 函数,而不是渲染正确情况下的  RX 函数,而不是渲染正确情况下的 RC 函数。RX 会将渲染出错的Suspense在HTML中对应的Comment标签  修改为 ,表示这个 Suspense 的 children 需要在浏览器端执行降级渲染。当执行 $RX 时,如果父组件已经完成 hydration,会调用 Comment 节点上的 _reactRetry 方法,立即执行对需要降级的组件的渲染;否则等待父组件执行时 hydration,再“顺道”执行渲染。

当 Suspense 的 children SSR 阶段渲染失败时,可以在 renderToPipeableStream 的 onError 回调中执行专门的逻辑处理,例如下面的例子中,会打印出错误日志,并将响应的状态码设置为 500。 

如果还没有渲染到任一 Suspense 组件时,就发生了错误,这意味着应用对应的整棵组件树都没有渲染成功,SSR 完全失败,这个时候 onShellReady 不会被调用,onShellError 会调用,我们可以在 onShellError 中返回 CSR 使用的 HTML 模版,让整个应用完全降级到 CSR 。

 let didError = false;
 const stream = renderToPipeableStream(
    ,
    {
      onShellReady() {
        // 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);
      },
      onError(x) {
        didError = true;
        console.error(x);
      },
      onShellError(x) {
        didError = true;
        res.send(...)//返回 CSR 使用的 HTML 模版,整棵组件树降级到 CSR  
      }
    }
  );

JS 和 CSS 设置

当前,我们还没有介绍如何在 Streaming SSR 中设置 JS 和 CSS 文件。有三种方式:

  • 在 HTML 组件中设置示例如下:
function Html({ assets, children, title }) {
    return (
      
        
          {title}
          
          
        
        
          

你可能感兴趣的:(React Streaming SSR原理示例深入解析)