React Server Components with Vite and Nitro.

app
assets
framework
action.tsx
client.tsx
index.css
root.tsx
package.json
tsconfig.json
vite.config.ts
import "./index.css"; // css import is automatically injected in exported server components
import viteLogo from "./assets/vite.svg";
import { getServerCounter, updateServerCounter } from "./action.tsx";
import reactLogo from "./assets/react.svg";
import nitroLogo from "./assets/nitro.svg";
import { ClientCounter } from "./client.tsx";

export function Root(props: { url: URL }) {
  return (
    <html lang="en">
      <head>
        {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */}
        <meta charSet="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Nitro + Vite + RSC</title>
      </head>
      <body>
        <App {...props} />
      </body>
    </html>
  );
}

function App(props: { url: URL }) {
  return (
    <div id="root">
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev/reference/rsc/server-components" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>

        <a href="https://nitro.build" target="_blank">
          <img src={nitroLogo} className="logo" alt="Nitro logo" />
        </a>
      </div>
      <h1>Vite + RSC + Nitro</h1>
      <div className="card">
        <ClientCounter />
      </div>
      <div className="card">
        <form action={updateServerCounter.bind(null, 1)}>
          <button>Server Counter: {getServerCounter()}</button>
        </form>
      </div>
      <div className="card">Request URL: {props.url?.href}</div>
      <ul className="read-the-docs">
        <li>
          Edit <code>src/client.tsx</code> to test client HMR.
        </li>
        <li>
          Edit <code>src/root.tsx</code> to test server HMR.
        </li>
        <li>
          Visit{" "}
          <a href="./_.rsc" target="_blank">
            <code>_.rsc</code>
          </a>{" "}
          to view RSC stream payload.
        </li>
        <li>
          Visit{" "}
          <a href="?__nojs" target="_blank">
            <code>?__nojs</code>
          </a>{" "}
          to test server action without js enabled.
        </li>
      </ul>
    </div>
  );
}
app/framework/request.tsx
// Framework conventions (arbitrary choices for this demo):
// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests
// - Use `x-rsc-action` header to pass server action ID
const URL_POSTFIX = "_.rsc";
const HEADER_ACTION_ID = "x-rsc-action";

// Parsed request information used to route between RSC/SSR rendering and action handling.
// Created by parseRenderRequest() from incoming HTTP requests.
type RenderRequest = {
  isRsc: boolean; // true if request should return RSC payload (via _.rsc suffix)
  isAction: boolean; // true if this is a server action call (POST request)
  actionId?: string; // server action ID from x-rsc-action header
  request: Request; // normalized Request with _.rsc suffix removed from URL
  url: URL; // normalized URL with _.rsc suffix removed
};

export function createRscRenderRequest(
  urlString: string,
  action?: { id: string; body: BodyInit }
): Request {
  const url = new URL(urlString);
  url.pathname += URL_POSTFIX;
  const headers = new Headers();
  if (action) {
    headers.set(HEADER_ACTION_ID, action.id);
  }
  return new Request(url.toString(), {
    method: action ? "POST" : "GET",
    headers,
    body: action?.body,
  });
}

export function parseRenderRequest(request: Request): RenderRequest {
  const url = new URL(request.url);
  const isAction = request.method === "POST";
  if (url.pathname.endsWith(URL_POSTFIX)) {
    url.pathname = url.pathname.slice(0, -URL_POSTFIX.length);
    const actionId = request.headers.get(HEADER_ACTION_ID) || undefined;
    if (request.method === "POST" && !actionId) {
      throw new Error("Missing action id header for RSC action request");
    }
    return {
      isRsc: true,
      isAction,
      actionId,
      request: new Request(url, request),
      url,
    };
  } else {
    return {
      isRsc: false,
      isAction,
      request,
      url,
    };
  }
}

::

此示例演示了使用 Vite 的实验性 RSC 插件和 Nitro 实现的 React Server Components(RSC)。其中包括服务器组件、客户端组件、服务器操作和流式 SSR。

#概览

SSR 入口处理传入的请求,并将 React 组件渲染为 HTML
根组件定义页面结构,作为服务器组件
客户端组件使用 "use client" 指令实现交互部分

#1. SSR 入口

app/framework/entry.ssr.tsx
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import React from "react";
import type { ReactFormState } from "react-dom/client";
import { renderToReadableStream } from "react-dom/server.edge";
import { injectRSCPayload } from "rsc-html-stream/server";
import type { RscPayload } from "./entry.rsc";

export default {
  fetch: async (request: Request) => {
    const rscEntryModule = await import.meta.viteRsc.loadModule<typeof import("./entry.rsc")>(
      "rsc",
      "index"
    );
    return rscEntryModule.default(request);
  },
};

export async function renderHTML(
  rscStream: ReadableStream<Uint8Array>,
  options: {
    formState?: ReactFormState;
    nonce?: string;
    debugNoJS?: boolean;
  }
): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> {
  // Duplicate one RSC stream into two.
  // - one for SSR (ReactClient.createFromReadableStream below)
  // - another for browser hydration payload by injecting <script>...FLIGHT_DATA...</script>.
  const [rscStream1, rscStream2] = rscStream.tee();

  // Deserialize RSC stream back to React VDOM
  let payload: Promise<RscPayload> | undefined;
  function SsrRoot() {
    // Deserialization needs to be kicked off inside ReactDOMServer context
    // for ReactDOMServer preinit/preloading to work
    payload ??= createFromReadableStream<RscPayload>(rscStream1);
    return React.use(payload).root;
  }

  // Render HTML (traditional SSR)
  const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index");

  let htmlStream: ReadableStream<Uint8Array>;
  let status: number | undefined;

  try {
    htmlStream = await renderToReadableStream(<SsrRoot />, {
      bootstrapScriptContent: options?.debugNoJS ? undefined : bootstrapScriptContent,
      nonce: options?.nonce,
      formState: options?.formState,
    });
  } catch {
    // fallback to render an empty shell and run pure CSR on browser,
    // which can replay server component error and trigger error boundary.
    status = 500;
    htmlStream = await renderToReadableStream(
      <html>
        <body>
          <noscript>Internal Server Error: SSR failed</noscript>
        </body>
      </html>,
      {
        bootstrapScriptContent:
          `self.__NO_HYDRATE=1;` + (options?.debugNoJS ? "" : bootstrapScriptContent),
        nonce: options?.nonce,
      }
    );
  }

  let responseStream: ReadableStream<Uint8Array> = htmlStream;
  if (!options?.debugNoJS) {
    // Initial RSC stream is injected in HTML stream as <script>...FLIGHT_DATA...</script>
    // using utility made by devongovett https://github.com/devongovett/rsc-html-stream
    responseStream = responseStream.pipeThrough(
      injectRSCPayload(rscStream2, {
        nonce: options?.nonce,
      })
    );
  }

  return { stream: responseStream, status };
}

SSR 入口负责处理渲染流程。它会加载 RSC 入口模块,复制 RSC 流(一个用于 SSR,另一个用于 hydration),将流反序列化回 React VDOM,并将其渲染为 HTML。RSC 负载会被注入 HTML,以便客户端进行 hydration。

#2. 根服务器组件

app/root.tsx
import "./index.css"; // css import is automatically injected in exported server components
import viteLogo from "./assets/vite.svg";
import { getServerCounter, updateServerCounter } from "./action.tsx";
import reactLogo from "./assets/react.svg";
import nitroLogo from "./assets/nitro.svg";
import { ClientCounter } from "./client.tsx";

export function Root(props: { url: URL }) {
  return (
    <html lang="en">
      <head>
        {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */}
        <meta charSet="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Nitro + Vite + RSC</title>
      </head>
      <body>
        <App {...props} />
      </body>
    </html>
  );
}

function App(props: { url: URL }) {
  return (
    <div id="root">
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev/reference/rsc/server-components" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>

        <a href="https://nitro.build" target="_blank">
          <img src={nitroLogo} className="logo" alt="Nitro logo" />
        </a>
      </div>
      <h1>Vite + RSC + Nitro</h1>
      <div className="card">
        <ClientCounter />
      </div>
      <div className="card">
        <form action={updateServerCounter.bind(null, 1)}>
          <button>Server Counter: {getServerCounter()}</button>
        </form>
      </div>
      <div className="card">Request URL: {props.url?.href}</div>
      <ul className="read-the-docs">
        <li>
          Edit <code>src/client.tsx</code> to test client HMR.
        </li>
        <li>
          Edit <code>src/root.tsx</code> to test server HMR.
        </li>
        <li>
          Visit{" "}
          <a href="./_.rsc" target="_blank">
            <code>_.rsc</code>
          </a>{" "}
          to view RSC stream payload.
        </li>
        <li>
          Visit{" "}
          <a href="?__nojs" target="_blank">
            <code>?__nojs</code>
          </a>{" "}
          to test server action without js enabled.
        </li>
      </ul>
    </div>
  );
}

服务器组件只在服务器上运行。它们可以直接导入 CSS、使用服务器端数据并调用服务器操作。ClientCounter 组件虽然被导入,但由于它包含 "use client" 指令,因此会在客户端运行。

#3. 客户端组件

app/client.tsx
"use client";

import React from "react";

export function ClientCounter() {
  const [count, setCount] = React.useState(0);

  return <button onClick={() => setCount((count) => count + 1)}>Client Counter: {count}</button>;
}

"use client" 指令将此组件标记为客户端组件。它会在浏览器上进行 hydration,并处理交互状态。服务器组件可以导入并渲染客户端组件,但客户端组件不能导入服务器组件。

#Learn More