你不知道的React系列(四十四)ReactDOMServer

409 阅读5分钟

概览

允许你将组件渲染成静态HTML标签用于服务端进行渲染

不支持流的环境

  • renderToString

    const html = renderToString(reactNode)
    
    • 将 React 元素渲染为 HTML
    • 不支持流
    • Suspense 有限支持,立即返回 fallback
    • 可以在客户端使用不推荐
    • 客户端使用 hydrate
    • 如果在客户端转化组件为 HTML
      import { createRoot } from 'react-dom/client';
      import { flushSync } from 'react-dom';
      
      const div = document.createElement('div');
      const root = createRoot(div);
      flushSync(() => {
        root.render(<MyIcon />);
      });
      console.log(div.innerHTML); // For example, "<svg>...</svg>"
      
  • renderToStaticMarkup

    const html = renderToStaticMarkup(reactNode)
    
    • 渲染没有交互逻辑的组件为 HTML 字符串

      import { renderToStaticMarkup } from 'react-dom/server';
      
      // The route handler syntax depends on your backend framework
      app.use('/', (request, response) => {
        const html = renderToStaticMarkup(<Page />);
        response.send(html);
      });
      
    • React 作为静态页面生成工具或者渲染静态内容(Email)

    • 客户端不能使用 hydrated

Node流环境

  • renderToPipeableStream

    Node 环境使用,Demo或者其他运行时使用 renderToReadableStream

    const { pipe, abort } = renderToPipeableStream(reactNode, options?)
    

    渲染 react 组件为 HTML 转化为 Node 流

    reactNode 节点 ,render 函数需要 return html 标签

    • options

      bootstrapScriptContent、bootstrapModules、identifierPrefix、namespaceURI、nonce、onAllReady、onError、onShellError、progressiveChunkSize...

      • bootstrapScripts

        注入 <script> 标签数组

      • onShellReady

        initial shell 渲染完后这个回调函数会执行,你能设置响应信息,开始使用流的相关功能。React 开始把其他内容变为流。script 标签替换 fallback

    • pipe HTML 转化为 Writable Node.js Stream

    • abort 停止服务端渲染然后渲染客户单剩下的内容

    • 渲染react元素为原始html

      export default function App() {
        return (
          <html>
            <head>
              <meta charSet="utf-8" />
              <meta name="viewport" content="width=device-width, initial-scale=1" />
              <link rel="stylesheet" href="/styles.css"></link>
              <title>My app</title>
            </head>
            <body>
              <Router />
            </body>
          </html>
        );
      }
      
      import { renderToPipeableStream } from 'react-dom/server';
      
      // The route handler syntax depends on your backend framework
      app.use('/', (request, response) => {
        const { pipe } = renderToPipeableStream(<App />, {
          bootstrapScripts: ['/main.js'],
          onShellReady() {
            response.setHeader('content-type', 'text/html');
            pipe(response);
          }
        });
      })
      // 客户端
      import { hydrateRoot } from 'react-dom/client';
      import App from './App.js';
      
      hydrateRoot(document, <App />);
      
    • 加载时展示更多内容

      使用 Suspense 延迟加载耗费时间的内容从而让其他内容显示

      function ProfilePage() {
        return (
          <ProfileLayout>
            <ProfileCover />
            <Suspense fallback={<BigSpinner />}>
              <Sidebar>
                <Friends />
                <Photos />
              </Sidebar>
              <Suspense fallback={<PostsGlimmer />}>
                <Posts />
              </Suspense>
            </Suspense>
          </ProfileLayout>
        );
      }
      
    • 如何分割 shell

      不被 Suspense 包裹的组件成为 shell

      当 onShellReady 触发时,有可能 Suspense 还是加载状态

      通过 Suspense 把应用分割为不同的 shell可以解决用户体验问题。

    • 处理服务端报错信息

      使用 onError 函数

      const { pipe } = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/main.js'],
        onShellReady() {
          response.setHeader('content-type', 'text/html');
          pipe(response);
        },
        onError(error) {
          console.error(error);
          logServerCrashReport(error);
        }
      });
      
    • shell 内部报错如何处理

      使用 onShellReady 设置错误的响应信息

      • 渲染组件报错,React 不会任何 HTML 到客户端

      • 手动返回一些提示信息 onShellError

      • 设置响应信息

    • shell 外部报错如何处理

      • 触发 fallback 组件
      • 服务端放弃渲染包裹组件
      • 客户端重新渲染包裹组件
      • 如果客户端报错就抛出,按照相关渲染逻辑进行。成功就会替换 fallback,触发服务端 onError 和客户端 onRecoverableError 回调
      • onShellReady 代替 onShellError 触发
    • 可以重新定义状态码

      let didError = false;
      
      const { pipe } = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/main.js'],
        onShellReady() {
          response.statusCode = didError ? 500 : 200;
          response.setHeader('content-type', 'text/html');
          pipe(response);
        },
        onShellError(error) {
          response.statusCode = 500;
          response.setHeader('content-type', 'text/html');
          response.send('<h1>Something went wrong</h1>'); 
        },
        onError(error) {
          didError = true;
          console.error(error);
          logServerCrashReport(error);
        }
      });
      
    • 自定义处理错误逻辑

      let didError = false;
      let caughtError = null;
      
      function getStatusCode() {
        if (didError) {
          if (caughtError instanceof NotFoundError) {
            return 404;
          } else {
            return 500;
          }
        } else {
          return 200;
        }
      }
      
      const { pipe } = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/main.js'],
        onShellReady() {
          response.statusCode = getStatusCode();
          response.setHeader('content-type', 'text/html');
          pipe(response);
        },
        onShellError(error) {
         response.statusCode = getStatusCode();
         response.setHeader('content-type', 'text/html');
         response.send('<h1>Something went wrong</h1>'); 
        },
        onError(error) {
          didError = true;
          caughtError = error;
          console.error(error);
          logServerCrashReport(error);
        }
      });
      
    • 爬虫和静态网站生成需要等待所有内容加载完成

      使用 onAllReady

      let didError = false;
      let isCrawler = // ... depends on your bot detection strategy ...
      
      const { pipe } = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/main.js'],
        onShellReady() {
          if (!isCrawler) {
            response.statusCode = didError ? 500 : 200;
            response.setHeader('content-type', 'text/html');
            pipe(response);
          }
        },
        onShellError(error) {
          response.statusCode = 500;
          response.setHeader('content-type', 'text/html');
          response.send('<h1>Something went wrong</h1>'); 
        },
        onAllReady() {
          if (isCrawler) {
            response.statusCode = didError ? 500 : 200;
            response.setHeader('content-type', 'text/html');
            pipe(response);      
          }
        },
        onError(error) {
          didError = true;
          console.error(error);
          logServerCrashReport(error);
        }
      });
      
    • 停止服务端的渲染

      const { pipe, abort } = renderToPipeableStream(<App />, {
        // ...
      });
      
      setTimeout(() => {
        abort();
      }, 10000);
      

      触发 fallback然后渲染客户端的其他内容

  • renderToNodeStream()(已废弃)

    ReactDOMServer.renderToNodeStream(element)

    • 返回一个可输出 HTML 字符串utf-8 编码的可读流iconv-lite

    • 在已有服务端渲染标记的节点上调用 ReactDOM.hydrate() 方法,React 将会保留该节点且只进行事件处理绑定

  • renderToStaticNodeStream

    const stream = renderToStaticNodeStream(reactNode)
    
    • 渲染没有交互逻辑的组件为 Node.js Readable Stream

      import { renderToStaticNodeStream } from 'react-dom/server';
      
      // The route handler syntax depends on your backend framework
      app.use('/', (request, response) => {
        const stream = renderToStaticNodeStream(<Page />);
        stream.pipe(response);
      });
      
    • 不能使用 hydrated

    • 等待 Suspense 完成

    • 这个方法缓存所有输出

    • 返回内容是 utf-8 字节流

web 流(browsers, Deno, and some modern edge runtimes)

renderToReadableStream

const stream = await renderToReadableStream(reactNode, options?)
  • 依赖 Web Streams,Node 环境使用 Readable Web Stream

  • 渲染react元素为原始html

    async function handler(request) {
      let didError = false;
      let caughtError = null;
    
      function getStatusCode() {
        if (didError) {
          if (caughtError instanceof NotFoundError) {
            return 404;
          } else {
            return 500;
          }
        } else {
          return 200;
        }
      }
    
      try {
        const stream = await renderToReadableStream(<App />, {
          bootstrapScripts: ['/main.js'],
          onError(error) {
            didError = true;
            caughtError = error;
            console.error(error);
            logServerCrashReport(error);
          }
        });
        return new Response(stream, {
          status: getStatusCode(),
          headers: { 'content-type': 'text/html' },
        });
      } catch (error) {
        return new Response('<h1>Something went wrong</h1>', {
          status: getStatusCode(),
          headers: { 'content-type': 'text/html' },
        });
      }
    }
    
  • 爬虫和静态网站生成需要等待所有内容加载完成

    async function handler(request) {
      try {
        let didError = false;
        const stream = await renderToReadableStream(<App />, {
          bootstrapScripts: ['/main.js'],
          onError(error) {
            didError = true;
            console.error(error);
            logServerCrashReport(error);
          }
        });
        let isCrawler = // ... depends on your bot detection strategy ...
        if (isCrawler) {
          await stream.allReady;
        }
        return new Response(stream, {
          status: didError ? 500 : 200,
          headers: { 'content-type': 'text/html' },
        });
      } catch (error) {
        return new Response('<h1>Something went wrong</h1>', {
          status: 500,
          headers: { 'content-type': 'text/html' },
        });
      }
    }
    
  • 停止服务端的渲染

    async function handler(request) {
      try {
        const controller = new AbortController();
        setTimeout(() => {
          controller.abort();
        }, 10000);
    
        const stream = await renderToReadableStream(<App />, {
          signal: controller.signal,
          bootstrapScripts: ['/main.js'],
          onError(error) {
            didError = true;
            console.error(error);
            logServerCrashReport(error);
          }
        });
        // ...
    

获取 CSS 和 JS 文件路径

使用 bootstrapScripts,bootstrapScriptContent

// You'd need to get this JSON from your build tooling.
const assetMap = {
  'styles.css': '/styles.123456.css',
  'main.js': '/main.123456.js'
};

app.use('/', (request, response) => {
  const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
    // Careful: It's safe to stringify() this because this data isn't user-generated.
    bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
    bootstrapScripts: [assetMap['main.js']],
    onShellReady() {
      response.setHeader('content-type', 'text/html');
      pipe(response);
    }
  });
});

export default function App({ assetMap }) {
  return (
    <html>
      <head>
        ...
        <link rel="stylesheet" href={assetMap['styles.css']}></link>
        ...
      </head>
      ...
    </html>
  );
}

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(document, <App assetMap={window.assetMap} />);

hydration 错误

  • 生成的 HTML 有多余的空白字符
  • 渲染逻辑使用 typeof window !== 'undefined'
  • 渲染逻辑使用浏览器 API window.matchMedia
  • 服务端和客户端渲染不同的数据