主分支
分支
主分支 (6.23.1)开发分支
版本
6.23.1v4/5.xv3.x
服务器端渲染

服务器端渲染

React Router 中最基本的服务器端渲染非常简单。但是,除了让正确的路由渲染之外,还有很多需要考虑的事情。以下是一些你需要处理的事情的非完整列表

  • 为服务器和浏览器捆绑你的代码
  • 不要将仅限服务器的代码捆绑到浏览器捆绑包中
  • 在服务器和浏览器上都能正常工作的代码拆分
  • 服务器端数据加载,这样你实际上就有东西可以渲染
  • 在客户端和服务器上都能正常工作的加载数据策略
  • 处理服务器和客户端中的代码拆分
  • 正确的 HTTP 状态码和重定向
  • 环境变量和密钥
  • 部署

将所有这些设置好可能非常复杂,但值得你付出努力,因为只有在服务器端渲染时才能获得性能和 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,你还需要管理将服务器端 Promise 序列化到客户端(提示,只需使用 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,如果这是你的目标。但如果你选择手动操作,以下是一些你可能需要考虑的其他概念

水化

服务器端渲染的核心概念是 水化,它涉及将客户端 React 应用程序“附加”到服务器端渲染的 HTML。为了正确执行此操作,我们需要在服务器渲染期间创建客户端 React Router 应用程序,使其处于与服务器渲染期间相同的状态。当你的服务器渲染通过 loader 函数加载数据时,我们需要将此数据发送上来,以便我们可以使用相同的加载器数据创建客户端路由器,以进行初始渲染/水化。

本指南中显示的 <StaticRouterProvider>createBrowserRouter 的基本用法会为你内部处理此操作,但如果你需要控制水化过程,可以通过 <StaticRouterProvider hydrate={false} /> 禁用自动水化过程。

在一些高级用例中,您可能希望部分地水化客户端 React Router 应用程序。您可以通过传递给 createBrowserRouterfuture.v7_partialHydration 标志来实现这一点。

重定向

如果任何加载器重定向,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} />
);

另请参阅

没有数据路由器

首先,您需要某种“应用程序”或“根”组件,该组件在服务器和浏览器中渲染。

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 服务器。请注意 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

最后,您需要一个类似的文件来使用您的 JavaScript 包“水化”应用程序,该包包含相同的 App 组件。请注意 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 应用程序的最佳方式——也许也是构建任何 React 应用程序的最佳方式 😉。