前言

不久前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

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

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

renderToPipeableStream

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

  1. // react-dom/src/server/ReactDOMFizzServerNode.js
  2. // 类型定义
  3. type Options = {
  4. identifierPrefix?: string,
  5. namespaceURI?: string,
  6. nonce?: string,
  7. bootstrapScriptContent?: string,
  8. bootstrapScripts?: Array<string>,
  9. bootstrapModules?: Array<string>,
  10. progressiveChunkSize?: number,
  11. // 在至少有一个root fallback(Suspense中的)可以显示时被调用
  12. onCompleteShell?: () => void,
  13. // 在shell完成前报错时调用,可以用于返回别的结果
  14. onErrorShell?: () => void,
  15. // 在完成所有等待任务后调用,但可能还没有flushed。
  16. onCompleteAll?: () => void,
  17. onError?: (error: mixed) => void,
  18. };
  19. type Controls = {
  20. // 取消等待中的I/O,切换到客户端渲染
  21. abort(): void,
  22. pipe<T: Writable>(destination: T): T,
  23. };
  24. function renderToPipeableStream(
  25. children: ReactNodeList,
  26. options?: Options,
  27. ): Controls

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

  1. import { renderToPipeableStream } from "react-dom/server";
  2. import App from "../src/App";
  3. import { DataProvider } from "../src/data";
  4. function render(url, res) {
  5. // res为writable response流
  6. res.socket.on("error", (error) => {
  7. console.error("Fatal", error);
  8. });
  9. let didError = false;
  10. const data = createServerData();
  11. // 返回一个Writable Stream
  12. const { pipe, abort } = renderToPipeableStream(
  13. <DataProvider data={data}>
  14. <App assets={assets} />
  15. </DataProvider>,
  16. {
  17. bootstrapScripts: [assets["main.js"]],
  18. onCompleteShell() {
  19. // Stream传输之前设置正确的状态码
  20. res.statusCode = didError ? 500 : 200;
  21. res.setHeader("Content-type", "text/html");
  22. pipe(res);
  23. },
  24. onErrorShell(x) {
  25. // 错误发生时替换外壳
  26. res.statusCode = 500;
  27. res.send('<!doctype><p>Error</p>');
  28. },
  29. onError(x) {
  30. didError = true;
  31. console.error(x);
  32. }
  33. }
  34. );
  35. // 放弃服务端渲染,切换到客户端渲染.
  36. setTimeout(abort, ABORT_DELAY);
  37. };

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