分支
主 (6.23.1)開發
版本
6.23.1v4/5.xv3.x
伺服器端渲染

服務器端渲染

React Router 中最基本的伺服器渲染非常直接。但是,需要考慮的因素遠不止讓正確的路由渲染。以下是不完整的清單,其中包含您需要處理的事項

  • 將您的程式碼打包到伺服器和瀏覽器
  • 不要將僅限伺服器的程式碼打包到瀏覽器套件中
  • 程式碼分割適用於伺服器和瀏覽器
  • 伺服器端資料載入,所以您實際上有一些東西可以渲染
  • 資料載入策略適用於用戶端和伺服器
  • 處理伺服器和用戶端中的程式碼分割
  • 正確的 HTTP 狀態碼和重新導向
  • 環境變數和密碼
  • 部署

良好地設定所有這些可能很複雜,但這些效能和 UX 特性只有在伺服器渲染時才可獲得,這些效能和 UX 特性是值得的。

如果您想要為 React Router 應用程式執行伺服器渲染,我們強烈建議您使用 Remix。這是在 React Router 的基礎上建立的另一個專案,它處理了上述所有事項以及更多事項。試試看吧!

如果您想自己解決,則需要使用 <StaticRouterProvider><StaticRouter> 伺服器,具體取決於您選擇的 路由器。如果使用 <StaticRouter>,請跳到 沒有資料路由器 區段。

使用資料路由器

首先,您需要為資料路由程式定義路由,這些路由將在伺服器和客戶端上使用

const React = require("react");
const { json, useLoaderData } = require("react-router-dom");

const routes = [
  {
    path: "/",
    loader() {
      return json({ message: "Welcome to React Router!" });
    },
    Component() {
      let data = useLoaderData();
      return <h1>{data.message}</h1>;
    },
  },
];

module.exports = routes;

我們在這些範例中使用 CJS 模組,目的是為了簡化伺服器上的操作,但一般而言,您會使用 ESM 模組並利用 esbuildvitewebpack 等套件打包器。

在定義了路由後,我們可以在 express 伺服器中建立處理常式程式並使用 createStaticHandler() 為路由載入資料。請記住,資料路由的主要目標是將資料擷取和呈現區分開來,因此您會看到在使用資料路由進行伺服器端呈現時,我們有不同的擷取和呈現步驟。

const express = require("express");
const {
  createStaticHandler,
} = require("react-router-dom/server");

const createFetchRequest = require("./request");
const routes = require("./routes");

const app = express();

let handler = createStaticHandler(routes);

app.get("*", async (req, res) => {
  let fetchRequest = createFetchRequest(req, res);
  let context = await handler.query(fetchRequest);

  // We'll tackle rendering next...
});

const listener = app.listen(3000, () => {
  let { port } = listener.address();
  console.log(`Listening on port ${port}`);
});

請注意,我們必須先將接收到的 Express 要求轉換為 Fetch 要求,這是靜態處理常式方法執行的要求。createFetchRequest 方法是 Express 要求所特有,而且在此範例中是從 @remix-run/express 介面中萃取出來的

module.exports = function createFetchRequest(req, res) {
  let origin = `${req.protocol}://${req.get("host")}`;
  // Note: This had to take originalUrl into account for presumably vite's proxying
  let url = new URL(req.originalUrl || req.url, origin);

  let controller = new AbortController();
  res.on("close", () => controller.abort());

  let headers = new Headers();

  for (let [key, values] of Object.entries(req.headers)) {
    if (values) {
      if (Array.isArray(values)) {
        for (let value of values) {
          headers.append(key, value);
        }
      } else {
        headers.set(key, values);
      }
    }
  }

  let init = {
    method: req.method,
    headers,
    signal: controller.signal,
  };

  if (req.method !== "GET" && req.method !== "HEAD") {
    init.body = req.body;
  }

  return new Request(url.href, init);
};

一旦我們透過執行所有與接收到的要求相符路由載入器的程式碼載入資料後,我們會使用 createStaticRouter()<StaticRouterProvider> 呈現 HTML 並將回應發送回瀏覽器

app.get("*", async (req, res) => {
  let fetchRequest = createFetchRequest(req, res);
  let context = await handler.query(fetchRequest);

  let router = createStaticRouter(
    handler.dataRoutes,
    context
  );
  let html = ReactDOMServer.renderToString(
    <StaticRouterProvider
      router={router}
      context={context}
    />
  );

  res.send("<!DOCTYPE html>" + html);
});

我們在此使用 renderToString 是為了簡化程序,因為我們已將資料載入 handler.query 中,而且我們在此簡易範例中未使用任何串流功能。如果您需要支援串流功能,則需要使用 renderToPipeableStream

如果您想要支援 defer,您還需要管理伺服器端承諾的序列化,並從網路傳送到客戶端(提示:直接使用 Remix,此處會透過 Scripts 元件為您處理 😉)。

一旦將 HTML 傳送到瀏覽器後,我們需要使用 createBrowserRouter()<RouterProvider> 在客戶端「插入」應用程式

import * as React from "react";
import * as ReactDOM from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";

import { routes } from "./routes";

let router = createBrowserRouter(routes);

ReactDOM.hydrateRoot(
  document.getElementById("app"),
  <RouterProvider router={router} />
);

這樣一來,您就會擁有已在伺服器端呈現且已插入的應用程式!如需工作範例,您也可以參考 Github 儲存庫中的 範例

其他概念

如前所述,伺服器端呈現在大規模環境和生產等級應用程式中可不容易,而且我們強烈建議您檢視 Remix,如果那是您的目標。但是,如果您想要親自操作,這裡有幾個可能會需要考慮的其他概念

插入

伺服器側渲染的核心概念是Hydration (水化),它包含了「附加」客戶端 React 應用程式到伺服器渲染的 HTML 中。要正確執行這項工作,我們需要在與伺服器渲染過程中相同的狀態下,建立我們的客戶端 React Router 應用程式。當您的伺服器渲染透過 loader 函數載入資料時,我們需要發送此資料,以便我們能為初始渲染/水化建立具有相同 loader 資料的客戶端路由器。

這份指南中展示的 <StaticRouterProvider>createBrowserRouter 的基本用法會在內部為您處理這件事,但如果您需要控制水化程序,您可以透過 <StaticRouterProvider hydrate={false} /> 來停用自動水化程序。

在一些進階使用案例中,您可能會想要部分水化客戶端 React Router 應用程式。您可以透過傳遞給 createBrowserRouterfuture.v7_partialHydration 標誌來執行此操作。

重新導向

如果任何 loader 重新導向,handler.query 會直接傳回 Response,因此您應該驗證它並發送重新導向回應,而不是嘗試渲染 HTML 文件

app.get("*", async (req, res) => {
  let fetchRequest = createFetchRequest(req, res);
  let context = await handler.query(fetchRequest);

  if (
    context instanceof Response &&
    [301, 302, 303, 307, 308].includes(context.status)
  ) {
    return res.redirect(
      context.status,
      context.headers.get("Location")
    );
  }

  // Render HTML...
});

延遲路由

如果您在路由中使用 route.lazy,則在客戶端上,您可能會擁有水化所需的所有資料,但您尚未擁有路由定義!理想的情況是,您的設定會在伺服器上判定配對的路由,並在臨界路徑上傳送他們的路由套件,這樣您就不會在您最初配對的路由上使用 lazy。然而,如果這不是事實,您需要載入這些路由,並在水化之前 就地更新它們,以避免路由員回退到載入狀態

// Determine if any of the initial routes are lazy
let lazyMatches = matchRoutes(
  routes,
  window.location
)?.filter((m) => m.route.lazy);

// Load the lazy matches and update the routes before creating your router
// so we can hydrate the SSR-rendered content synchronously
if (lazyMatches && lazyMatches?.length > 0) {
  await Promise.all(
    lazyMatches.map(async (m) => {
      let routeModule = await m.route.lazy();
      Object.assign(m.route, {
        ...routeModule,
        lazy: undefined,
      });
    })
  );
}

let router = createBrowserRouter(routes);

ReactDOM.hydrateRoot(
  document.getElementById("app"),
  <RouterProvider router={router} fallbackElement={null} />
);

另請參閱

沒有資料路由器

首先,您需要某種會在伺服器和瀏覽器中渲染的「app」或「root」組件

export default function App() {
  return (
    <html>
      <head>
        <title>Server Rendered App</title>
      </head>
      <body>
        <Routes>
          <Route path="/" element={<div>Home</div>} />
          <Route path="/about" element={<div>About</div>} />
        </Routes>
        <script src="/build/client.entry.js" />
      </body>
    </html>
  );
}

以下是簡單的 express 伺服器,它會在伺服器上渲染 app。請注意使用 StaticRouter

import express from "express";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";

let app = express();

app.get("*", (req, res) => {
  let html = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );
  res.send("<!DOCTYPE html>" + html);
});

app.listen(3000);

因為我們不會在這個簡單的範例中使用任何串流功能,所以我們在此使用 renderToString 以簡化流程。如果您需要支援串流功能,您會需要使用 renderToPipeableStream

最後,您需要類似的檔案來「水化」包含完全相同的 App 組件的 JavaScript 套件。請注意使用 BrowserRouter 而不是 StaticRouter

import * as ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

ReactDOM.hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.documentElement
);

與客戶端條目的唯一實際差異是

  • 使用 StaticRouter 而不是 BrowserRouter
  • 將 URL 從伺服器傳遞至 <StaticRouter url>
  • 使用 ReactDOMServer.renderToString 取代 ReactDOM.render

若要順利運作,有些部分需要自行處理。

  • 如何在瀏覽器和伺服器中匯編代碼,使其運作。
  • 如何知道 <App> 元件中的 <script> 的用戶端入口在哪裡。
  • 釐清資料載入(特別是 <title>)。

再次建議您參考 Remix。它是讓 React Router app 進行伺服器渲染的最佳方式,或許也是建置任何 React app 的最佳方式 😉。