流式SSR与react

933 阅读4分钟

简介

流式SSR和SSR的区别

SSR (Server Side Rendering) 和流式SSR是两种不同的渲染方式;SSR是指在服务器端将页面渲染成HTML,然后将HTML传输到客户端显示。这种方式可以提高页面展示速度和SEO效果,但也会增加服务器负担和网络传输量。

流式SSR (Streaming SSR) 是一种改进的SSR方式,在服务器端将页面分块渲染成HTML,然后按照顺序逐步将HTML传输到客户端,实现逐步渲染页面的效果。这种方式可以更快地展示页面内容,并且减少服务器负担和网络传输量。

对比gif图

SSR

ssr3.gif

Streaming ssr

ssr2.gif

流式 SSR 的性能优势:

  • 快速的 TTFB(首字节时间):浏览器通过流式传输 HTML 页面外壳,无需阻塞服务器端数据获取。
  • 渐进式加载:随着服务器端数据获取被解决,数据被流式传输在 HTML 响应中。React 运行时逐渐填充每个组件的状态,无需额外的客户端往返或阻塞渲染完整组件树。

基本工作原理

  • 基于Http的Transfer-Encoding`,数据会被拆分为一系列的chunk返回
  • 下面是基于基本的http模块展示的基本原理,可以直接node运行
const http = require('http');

const server = http.createServer(async (req, res) => {
  res.setHeader('Content-Type', 'text/html');
  res.setHeader('Transfer-Encoding', 'chunked')

  res.write('<html>');
  res.write('<head><title>Stream Demo</title><head>');
  res.write('<body>');

  await new Promise((resolve, reject) => setTimeout(resolve, 2000));
  //第一个http chunk
  res.write('<h2>first content</h2>');

  await new Promise((resolve, reject) => setTimeout(resolve, 2000));
  //第二个http chunk
  res.write('<h2>second content</h2>');
  res.write('</body></html>');
  res.end();
});

server.listen(8080);

流式SSR与react

React 18之前的SSR

在 React 18、Suspense 或任何新的streaming功能出现之前,React 中典型的 SSR 设置如下所示

  • server端
// server/index.js
import path from 'path';
import fs from 'fs';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Koa from 'koa';
import serve from 'koa-static';
import { App } from '../client/App';

const app = new Koa();

app.use(serve(path.resolve(__dirname, '../build')));

app.use(async (ctx) => {
  const appContent = ReactDOMServer.renderToString(<App />);
  const indexFile = path.resolve('./build/index.html');
  const htmlData = await fs.promises.readFile(indexFile, 'utf8');
  ctx.body = htmlData.replace('<div id="root"></div>', `<div id="root">${appContent}</div>`);
});

app.listen(8080, () => {
  console.log(`服务器正在监听端口 ${PORT}`);
});


// ./build/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>React App</title>
    <script src="main.js" async defer></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

  • 前端
// client/index.ts
import React from "react";
import ReactDOM from 'react-dom';
import { App } from './App';

// 渲染已经在server端完成,前端只需用ReactDOM.hydrate取代ReactDOM.render
ReactDOM.hydrate(<App />, document.getElementById('root'));

我们看下之前的这种SSR存在的问题:

  • 虽然服务器现在负责渲染 React 应用程序,但服务器端渲染的内容仍然是一大块 HTML,需要在传输到客户端之前一次性生成完整的 HTML,从而导致更慢的加载时间。
  • 由于 React 组件之间存在依赖关系,服务器必须等待获取所有数据,才能开始渲染组件、生成完整的 HTML 响应并将其发送到客户端,这也导致了更长的加载时间。
  • 客户端仍然需要加载整个应用程序的 JavaScript,这也会导致更长的加载时间。
  • 当组件开始进行水合作用时,整个过程必须一次完成,这意味着用户必须等待水合作用完成,才能与页面进行交互。在此期间,页面无法响应用户的操作,这也会影响用户体验。

React 18 流式SSR

React 18 允许我们对每个组件进行单独处理,并在加载新数据时连续流式传输内容,这可以通过用户在页面的其余部分仍在加载时率先可以对已经流式传输到达的组件进行交互,以此来提高用户体验。

要开始使用此功能,需要在服务器上使用 renderToPipeableStream 方法,而不是 renderToString,可以使用 <Suspense /> 组件来帮助进行代码拆分、请求数据和分块水合。

使用 <Suspense />代码分割请求

代码分割
import { Suspense, lazy } from 'react'  
  
const OtherComponent = lazy(() => import('./OtherComponent'))  
  
function MyComponent() {  
  return (  
    <div>  
        <Suspense fallback={<div>Loading...</div>}>  
            <OtherComponent />  
        </Suspense>  
    </div>  
   )  
}
请求

Suspense的使用注意事项:

  • 使用启用了 Suspense 的框架(如 Relay 和 Next.js)进行数据请求
  • 使用 lazy 进行组件的懒加载
  • Suspense 无法检测在 Effect 或事件处理程序中获取数据的情况。 目前尚不支持使用未使用意见框架的启用了 Suspense 的数据获取。实现启用了 Suspense 的数据源的要求是不稳定和未记录的。React 将在未来的版本中发布官方 API,以实现将数据源与 Suspense 集成。

自己集成异步请求代码示例如下

import { useQuery } from '@tanstack/react-query'
...

const fetchSomeData = () => wrapPromise(fetchPosts())

function fetchPosts() {
 return new Promise(resolve => {
   setTimeout(() => {
     resolve([
       { id: 0, text: 'Post 1' },
       { id: 1, text: "Post 2" },
       { id: 2, text: 'Post 3' }
      ])
    }, 1100)
  })
}

const resource = fetchSomeData()

export const Posts = () => {
  // notice we dont have to worry about loading state here
  // resource.read will throw a promise if data is not ready yet
  // so here posts will always be resolved!
  const posts = resource.read()

  // 也可以用react:实验性API `use` function (官方文档有个示例 :[codesandbox]   (https://codesandbox.io/s/s9zlw3?file=/Albums.js&utm_medium=sandpack)
  //[官网](https://react.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading)
  // import { use } from 'react'
  // const posts = use(fetchPosts())
  
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.text}</li>)}
    </ul>
   )
}


export function wrapPromise(promise) {
  let result
  let status = 'pending'
  const suspender = promise.then(
    r => {
      status = 'success'
      result = r
    },
    e => {
      status = 'error'
      result = e
    }
  )

  return {
    read() {
      if (status === 'pending') throw suspender
      else if (status === 'error') throw result

      return result
    }
  }
}

代码示例:codesandbox

参考React官方文档Suspense是如何配合data fetch的;实验性API真实实现的例子可参考本篇文章

使用renderToPipeableStream API代替之前的renderToString/renderToNodeString

import { renderToPipeableStream } from 'react-dom/server';

// 使用express举例
app.use('/', (request, response) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      response.setHeader('content-type', 'text/html');
      pipe(response);
    }
  });
});

参考文章