React 18: потоковая передача SSR с Suspense и выборка данных на сервере (Как сделать)


Проблема

Когда вы пытаетесь выполнить выборку данных на сервере с помощью потокового SSR с Suspense в React 18, вы сталкиваетесь с проблемой, и это несоответствие гидратации. Здесь мы расскажем, как ее решить (решение взято отсюда).

Решение

Вот код серверного приложения:

import express from "express";
import { renderToPipeableStream } from "react-dom/server";
import React from "react";
import AppServer from "../src/components/AppServer";
import path from "path";
import { DataProvider, data } from "../src/providers/data";
import { createServerData } from "../src/api/resource";
import { Writable } from "node:stream";

const app = express();
const port = 3000;
app.get("/", (req, res) => {
  const stream = new Writable({
    write(chunk, _encoding, cb) {
      res.write(chunk, cb);
    },
    final() {
      res.write(
        `<script>
        window.globalCache={comments:[${data.comments.map((c) => `'${c}'`)}]}
        </script>`
      );
      res.end("</body></html>");
    },
  });
  const { pipe } = renderToPipeableStream(
    <DataProvider data={createServerData()}>
      <AppServer />
    </DataProvider>,
    {
      bootstrapScripts: ["/main.js"],
      onShellReady() {
        res.write("<html><body>");
        pipe(stream);
      },
    }
  );
});

app.use(express.static(path.join(__dirname, "/../dist")));

app.listen(port, () => {
  console.log(`app running on port ${port}`);
});
Вход в полноэкранный режим Выйти из полноэкранного режима

Ключевой момент находится здесь:

 const stream = new Writable({
    write(chunk, _encoding, cb) {
      res.write(chunk, cb);
    },
    final() {
      res.write(
        `<script>
        window.globalCache={comments:[${data.comments.map((c) => `'${c}'`)}]}
        </script>`
      );
      res.end("</body></html>");
    },
  });
Войти в полноэкранный режим Выход из полноэкранного режима

Мы пишем скрипт в конце потока, чтобы заполнить переменную globalCache в браузере данными на сервере.

Именно отсюда берется data:

import React, { createContext, useContext } from "react";

export let data;

const DataContext = createContext(null);

export function DataProvider({ children, data }) {
  return <DataContext.Provider value={data}>{children}</DataContext.Provider>;
}

export function useData() {
  const ctx = useContext(DataContext);
  if (ctx) {
    data = ctx.read();
  } else {
    data = window.globalCache;
  }
  return data;
}
Вход в полноэкранный режим Выход из полноэкранного режима

На сервере data считывается из контекста, а в браузере — из переменной globalCache. Так мы избегаем проблемы несоответствия гидратации.

Давайте посмотрим на функцию createServerData:

export function createServerData() {
    let done = false;
    let promise = null;
    let value
    return {
      read: ()=> {
        if (done) {
          return value
        }
        if (promise) {
          throw promise;
        }
        promise = new Promise((resolve) => {
          setTimeout(() => {
            done = true;
            promise = null;
            value={comments:['a','b','c']}
            resolve()
          }, 6000);
        });
        throw promise;
      }
    };
  }
Вход в полноэкранный режим Выход из полноэкранного режима

Это обещание, которое разрешается за 6000 мс.

Теперь давайте посмотрим, где мы используем хук useData, в компоненте Comments:

import React from "react";
import { useData } from "../providers/data";

export default function Comments() {
  const { comments } = useData();

  return (
    <ul>
      {comments && comments.map((comment, i) => <li key={i}>{comment}</li>)}
    </ul>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

На сервере он будет читать data из Context, в то время как в браузере он будет читать data из глобальной переменной globalCache. Это происходит потому, что в браузере контекст не будет определен, потому что в случае браузера мы не оборачиваем компонент App с DataProvider:

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./components/App";

hydrateRoot(document.getElementById("root"), <App />);
Вход в полноэкранный режим Выход из полноэкранного режима

Вот как выглядит компонент App:

import React, { Suspense, lazy } from "react";

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

const App = () => (
  <>
    <Suspense fallback={<div>loading...</div>}>
      <Comments />
    </Suspense>
  </>
);

export default App;
Вход в полноэкранный режим Выход из полноэкранного режима

А вот как выглядит компонент AppServer, использованный выше (в сервере):

import React from "react";
import App from "./App";

const AppServer = () => (
      <div id="root"> 
        <App />
       </div> 
);

export default AppServer;
Вход в полноэкранный режим Выход из полноэкранного режима

На этом мы рассмотрели весь код этого примера о том, как сделать потоковый SSR с Suspense и выборку данных на сервере в React 18, избегая проблемы несоответствия гидратации.

Оцените статью
Procodings.ru
Добавить комментарий