浅析React 18 Streaming SSR(流式服务端渲染)

15,014 阅读7分钟

前言

不久前 React 18 推出了第一个发布候选版本(18.0.0-rc.0),意味着 React 18 的所有特性已经趋于稳定,可以投入到生产测试中。其中一个重要特性就是本文要介绍的Streaming SSR with Suspense

服务端渲染(SSR)

首先,React 的服务端渲染(Server side rendering)是怎么做的?

在用户访问时,React SSR(下图中的 SSR with hydration 一类)将 React 组件提前在服务器渲染成 HTML 发送给客户端,这样客户端能够在 JavaScript 渲染完成前展示基本的静态 HTML 内容,减少白屏等待的时间。

然后在 JavaScript 加载完成后对已有的 HTML 组件进行 React 事件逻辑绑定(也就是 Hydration 过程),Hydration 完成后才是一个正常的 React 应用。
infographic.png
但是这类 SSR 同样存在弊端

  • 服务端需要准备好所有组件的 HTML 才能返回。如果某个组件需要的数据耗时较久,就会阻塞整个 HTML 的生成。
  • Hydration 是一次性的,用户需要等待客户端加载所有组件的 JavaScript 并 Hydrated 完成后才能和任一组件交互。(渲染逻辑复杂时,页面首次渲染到可交互之间可能存在较长的不可交互时间)
  • 在 React SSR 中不支持客户端渲染常用的代码分割组合React.lazySuspense

而在 React 18 中新的 SSR 架构React Fizz带来了两个主要新特性来解决上述的缺陷:Streaming HTML(流式渲染)和Selective Hydration(选择性注水)

流式渲染(Streaming HTML)

一般来说,流式渲染就是把 HTML 分块通过网络传输,然后客户端收到分块后逐步渲染,提升页面打开时的用户体验。通常是利用HTTP/1.1中的分块传输编码(Chunked transfer encoding)机制。

renderToNodeStream

早在 React 16 中同样有一个用于流式传输的 APIrenderToNodeStream来返回一个可读的流(然后就可以将这个流 pipe 给 node.js 的 response 流)给客户端渲染,比原始的renderToString有着更短的 TFFB 时间。

TFFB:Time To First Byte,发出页面请求到接收到应答数据第一个字节所花费的毫秒数

L3Byb3h5L2h0dHBzL2ltYWdlczIwMTguY25ibG9ncy5jb20vYmxvZy81OTYxNTcvMjAxODAzLzU5NjE1Ny0yMDE4MDMzMTE5MjA1NTcyNi0xMzk5MDIyMzIxLnBuZw==.png

app.get("/", (req, res) => {
  res.write(
    "<!DOCTYPE html><html><head><title>Hello World</title></head><body>"
  );
  res.write("<div id='root'>");
  const stream = renderToNodeStream(<App />);
  stream.pipe(res, { end: false });
  stream.on("end", () => {
    // 流结束后再写入剩余的HTML部分
    res.write("</div></body></html>");
    res.end();
  });
});

但是renderToNodeStream需要从 DOM 树自顶向下开始渲染,并不能等待某个组件的数据然后渲染其他部分的 HTML(如下图的效果)。该 API 会在 React 18 中正式废弃。

renderToPipeableStream

而新推出renderToPipeableStream API 则同时具备 Streaming 和 Suspense 的特性,不过在用法上更复杂。

// react-dom/src/server/ReactDOMFizzServerNode.js
// 类型定义

type Options = {
  identifierPrefix?: string,
  namespaceURI?: string,
  nonce?: string,
  bootstrapScriptContent?: string,
  bootstrapScripts?: Array<string>,
  bootstrapModules?: Array<string>,
  progressiveChunkSize?: number,
  // 在至少有一个root fallback(Suspense中的)可以显示时被调用
  onCompleteShell?: () => void,
  // 在shell完成前报错时调用,可以用于返回别的结果
  onErrorShell?: () => void,
  // 在完成所有等待任务后调用,但可能还没有flushed。
  onCompleteAll?: () => void,
  onError?: (error: mixed) => void,
};

type Controls = {
  // 取消等待中的I/O,切换到客户端渲染
  abort(): void,
  pipe<T: Writable>(destination: T): T,
};

function renderToPipeableStream(
  children: ReactNodeList,
  options?: Options,
): Controls

这里以 React 官方给出的第一个Demo作为例子。

import { renderToPipeableStream } from "react-dom/server";
import App from "../src/App";
import { DataProvider } from "../src/data";

function render(url, res) {
  // res为writable response流
  res.socket.on("error", (error) => {
    console.error("Fatal", error);
  });
  let didError = false;
  const data = createServerData();
  // 返回一个Writable Stream
  const { pipe, abort } = renderToPipeableStream(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>,
    {
      bootstrapScripts: [assets["main.js"]],
      onCompleteShell() {
        // Stream传输之前设置正确的状态码
        res.statusCode = didError ? 500 : 200;
        res.setHeader("Content-type", "text/html");
        pipe(res);
      },
      onErrorShell(x) {
        // 错误发生时替换外壳
        res.statusCode = 500;
        res.send("<!doctype><p>Error</p>");
      },
      onError(x) {
        didError = true;
        console.error(x);
      },
    }
  );
  // 放弃服务端渲染,切换到客户端渲染.
  setTimeout(abort, ABORT_DELAY);
}

68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f704e6550316c4253546261616162726c4c71707178413f613d716d636f563745617955486e6e69433643586771456961564a52637145416f56726b39666e4e564646766361.png
以下截取自实际传输的 HTML,最初被 Suspense 的 Comment 组件还没准备好,返回的只有占位的 Spinner。每个被 Suspense 的组件都有一个对用户不可见的带注释和 id 的template占位符用来记录已传输状态的块(Chunks),这些占位符后续会被有效的组件填充。

template 可以用于任意标签类型组件的子组件,因此被用作占位符。

数据结构

一次带有 Suspense 的渲染可以划分为以下数据结构,最底层的 Chunk 就是字符串或者基本的 HTML 片段。

  • Request
    • SuspenseBoundary
      • Segment
        • Chunk

源码:ReactFizzServer.js

// 状态
const PENDING = 0;
const COMPLETED = 1;
const FLUSHED = 2;
const ABORTED = 3;
const ERRORED = 4;

type PrecomputedChunk = Uint8Array;
type Chunk = string;

type Segment = {
  status: 0 | 1 | 2 | 3 | 4,
  // typically a segment will be flushed by its parent, except if its parent was already flushed
  parentFlushed: boolean,
  // starts as 0 and is lazily assigned if the parent flushes early
  id: number,
  // the index within the parent's chunks or 0 at the root
  +index: number,
  +chunks: Array<Chunk | PrecomputedChunk>,
  +children: Array<Segment>,
  // The context that this segment was created in.
  formatContext: FormatContext,
  // If this segment represents a fallback, this is the content that will replace that fallback.
  +boundary: null | SuspenseBoundary,
};

type SuspenseBoundary = {
  id: SuspenseBoundaryID,
  rootSegmentID: number,
  // if it errors or infinitely suspends
  forceClientRender: boolean,
  parentFlushed: boolean,
  // when it reaches zero we can show this boundary's content
  pendingTasks: number,
  // completed but not yet flushed segments.
  completedSegments: Array<Segment>,
  // used to determine whether to inline children boundaries.
  byteSize: number,
  // used to cancel task on the fallback if the boundary completes or gets canceled.
  fallbackAbortableTasks: Set<Task>,
};

type Request = {
	destination: null | Destination,
  +responseState: ResponseState,
  +progressiveChunkSize: number,
  status: 0 | 1 | 2,
  fatalError: mixed,
  nextSegmentId: number,
  // when it reaches zero, we can close the connection.
  allPendingTasks: number,
  // when this reaches zero, we've finished at least the root boundary.
  pendingRootTasks: number,
  // Completed but not yet flushed root segments.
  completedRootSegment: null | Segment,
  abortableTasks: Set<Task>,
  // Queues to flush in order of priority
  pingedTasks: Array<Task>,
  // Errored or client rendered but not yet flushed.
  clientRenderedBoundaries: Array<SuspenseBoundary>,
  // Completed but not yet fully flushed boundaries to show.
  completedBoundaries: Array<SuspenseBoundary>,
  // Partially completed boundaries that can flush its segments early.
  partialBoundaries: Array<SuspenseBoundary>,
  onError: (error: mixed) => void,
  onCompleteAll: () => void,
  onCompleteShell: () => void,
  onErrorShell: (error: mixed) => void,
}

占位符格式

不同的 ID 前缀尾代表不同作用的元素:

  • Placeholder(占位块):P:
  • Segment(要插入的有效片段):S:,一般是div,表格,数学公式,SVG 会用对应的元素
  • Boundary(Suspense 边界):B:
  • IdR:

不同的注释标注开头代表不同 Suspense 边界 (Suspense boundaries)状态的块:

为了不影响 CSS 选择器和展示效果,Suspense Boundary 不是一个具体片段或者自定义标签

  • Completed(已完成):<!--$-->
  • Pending(等待中):<!--$?-->
  • ClientRendered(客户端已渲染):<!--$!-->

标注结束的注释则统一为<!--/$-->

渲染流程

主体内容部分除了最上层的 main 是 Completed,其他的 sidebar,post 和 comments 组件都是 Pending 中:

<body>
  <noscript><b>Enable JavaScript to run this app.</b></noscript>
  <!--$-->
  <main>
    <nav><a href="/">Home</a></nav>
    <aside class="sidebar">
      <!--$?-->
      <template id="B:0"></template>
      <div
        class="spinner spinner--active"
        role="progressbar"
        aria-busy="true"
      ></div>
      <!--/$-->
    </aside>
    <article class="post">
      <!--$?-->
      <template id="B:1"></template>
      <div
        class="spinner spinner--active"
        role="progressbar"
        aria-busy="true"
      ></div>
      <!--/$-->
      <section class="comments">
        <h2>Comments</h2>
        <!--$?-->
        <template id="B:2"></template>
        <div
          class="spinner spinner--active"
          role="progressbar"
          aria-busy="true"
        ></div>
        <!--/$-->
      </section>
      <h2>Thanks for reading!</h2>
    </article>
  </main>
  <!--/$-->
</body>

HTML 中还带有用于替换占位符为实际组件的脚本:

具体实现:ReactDOMServerFormatConfig.js

<script>
  // function completeSegment(containerID, placeholderID)
  function $RS(a, b) {
    // ...
  }
</script>

<script>
  // function completeBoundary(suspenseBoundaryID, contentID)
  function $RC(a, b) {
    // ...
  }
</script>

<script>
  // function clientRenderBoundary(suspenseBoundaryID)
  function $Rx(a, b) {
    // ...
  }
</script>

下面用replaceChildren来简单展示

replaceChildren是 2020 年推出的试验性 DOM API,目前主流浏览器都已经提供支持。

<div hidden id="comments">
  <!-- Comments -->
  <p>foo</p>
  <p>bar</p>
</div>
<script>
  // 新的替换子元素API
  document
    .getElementById("sections-spinner")
    .replaceChildren(document.getElementById("comments"));
</script>

组件 Suspense 结束后继续传输准备好的评论组件和用于替换占位符的脚本,浏览器解析后就能实现“增量渲染”。

<div hidden id="S:2"><template id="P:5"></template></div>
<div hidden id="S:5">
  <p class="comment">Wait, it doesn&#x27;t wait for React to load?</p>
  <p class="comment">How does this even work?</p>
  <p class="comment">I like marshmallows</p>
</div>
<script>
  $RS("S:5", "P:5");
</script>
<script>
  $RC("B:2", "S:2");
</script>

这样就完成了一次 HTML 的服务器流式渲染,在这个阶段客户端 JavaScript 可能尚未加载。

最终生成的 HTML 会保留要插入的内容片段(有隐藏的标记,用户不可见),虽然因为Suspense和 Streaming 的关系不能保证顺序和 DOM 顺序一致,但应该不影响 SEO 的效果。

React 18 的另一个新特性 React Server Component 同样用到了服务器的流式传输,这里不做展开,感兴趣的可以查阅Plasmic的这篇React Server Component 深度解析

选择性注水 (Selective Hydration)

有了lazySuspense的支持,另一个特性就是 React SSR 能够尽早对已经就绪的页面部分注水,而不会被其他部分阻塞。从另一个角度看,在 React 18 中注水本身也是 lazy 的。

这样就可以将不需要同步加载的组件选择性地用lazySuspense包起来(和客户端渲染时一样)。而 React 注水的粒度取决于Suspense包含的范围,每一层Suspense就是一次注水的“层级”(要么组件都完成注水要么都没完成)。

import { lazy } from "react";

const Comments = lazy(() => import("./Comments.js"));

// ...

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>;

同样的,流式传输的 HTML 也不会阻塞注水过程。如果 JavaScript 早于 HTML 加载完成,React 就会开始对已完成的 HTML 部分注水。

React 通过维护几个优先队列,能够记录用户的交互点击来优先给对应组件注水,在注水完成后组件就会响应这次交互,即事件重放(event replay)。68747470733a2f2f717569702e636f6d2f626c6f622f5963474141416b314234322f5358524b357573725862717143534a3258396a4769673f613d77504c72596361505246624765344f4e305874504b356b4c566839384747434d774d724e5036374163786b61.png

总结

出于篇幅等原因,本文并没有对 React Fizz 架构作详细的解读,只是简要介绍了 Streaming SSR 的流式渲染和选择性注水的特性。在实际使用中用户只需要选择性地引入<Suspense>就能享受到 Streaming SSR 带来的巨大提升。值得一提的是 React 18.0 中的 SSR <Suspense>还不支持在请求数据时 Suspense,该特性或在 18.x 中和react-fetch和 Server Component 一起推出。

参考资料

Rendering on the Web
New Suspense SSR Architecture in React 18
Keeping browser interactive during hydration
Upgrading to React 18 on the server
What changes are planned for Suspense in 18
Library Upgrade Guide: (e.g. react-helmet)
Basic Fizz Architecture