# Vite RSC > 使用 Vite 和 Nitro 的 React 服务器组件。 ```text [.gitignore] node_modules dist ``` ```json [package.json] { "name": "@vitejs/plugin-rsc-examples-starter", "version": "0.0.0", "private": true, "license": "MIT", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4" }, "devDependencies": { "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.3", "@vitejs/plugin-rsc": "^0.5.19", "nitro": "latest", "rsc-html-stream": "^0.0.7", "vite": "beta" } } ``` ```json [tsconfig.json] { "extends": "nitro/tsconfig", "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], "types": ["vite/client", "@vitejs/plugin-rsc/types"], "jsx": "react-jsx" } } ``` ```ts [vite.config.ts] import { defineConfig } from "vite"; import { nitro } from "nitro/vite"; import rsc from "@vitejs/plugin-rsc"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [ nitro(), rsc({ serverHandler: false, entries: { ssr: "./app/framework/entry.ssr.tsx", rsc: "./app/framework/entry.rsc.tsx", }, }), react(), ], environments: { client: { build: { rollupOptions: { input: { index: "./app/framework/entry.browser.tsx" }, }, }, }, }, }); ``` ```tsx [app/action.tsx] "use server"; let serverCounter = 0; export async function getServerCounter() { return serverCounter; } export async function updateServerCounter(change: number) { serverCounter += change; } ``` ```tsx [app/client.tsx] "use client"; import React from "react"; export function ClientCounter() { const [count, setCount] = React.useState(0); return ; } ``` ```css [app/index.css] :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; color-scheme: light dark; color: rgba(255, 255, 255, 0.87); background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } a { font-weight: 500; color: #646cff; text-decoration: inherit; } a:hover { color: #535bf2; } body { margin: 0; display: flex; place-items: center; min-width: 320px; min-height: 100vh; } h1 { font-size: 3.2em; line-height: 1.1; } button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: #1a1a1a; cursor: pointer; transition: border-color 0.25s; } button:hover { border-color: #646cff; } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { :root { color: #213547; background-color: #ffffff; } a:hover { color: #747bff; } button { background-color: #f9f9f9; } } #root { max-width: 1280px; margin: 0 auto; padding: 2rem; text-align: center; } .logo { height: 6em; padding: 1.5em; will-change: filter; transition: filter 300ms; } .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } .logo.react:hover { filter: drop-shadow(0 0 2em #61dafbaa); } @keyframes logo-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; } } .card { padding: 1rem; } .read-the-docs { color: #888; text-align: left; } ``` ```tsx [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 ( {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */} Nitro + Vite + RSC ); } function App(props: { url: URL }) { return (
Vite logo React logo Nitro logo

Vite + RSC + Nitro

Request URL: {props.url?.href}
  • Edit src/client.tsx to test client HMR.
  • Edit src/root.tsx to test server HMR.
  • Visit{" "} _.rsc {" "} to view RSC stream payload.
  • Visit{" "} ?__nojs {" "} to test server action without js enabled.
); } ``` ```text [app/assets/nitro.svg] ``` ```text [app/assets/react.svg] ``` ```text [app/assets/vite.svg] ``` ```tsx [app/framework/entry.browser.tsx] import { createFromReadableStream, createFromFetch, setServerCallback, createTemporaryReferenceSet, encodeReply, } from "@vitejs/plugin-rsc/browser"; import React from "react"; import { createRoot, hydrateRoot } from "react-dom/client"; import { rscStream } from "rsc-html-stream/client"; import { GlobalErrorBoundary } from "./error-boundary"; import type { RscPayload } from "./entry.rsc"; import { createRscRenderRequest } from "./request"; async function main() { // Stash `setPayload` function to trigger re-rendering // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) let setPayload: (v: RscPayload) => void; // Deserialize RSC stream back to React VDOM for CSR const initialPayload = await createFromReadableStream( // Initial RSC stream is injected in SSR stream as rscStream ); // Browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload); React.useEffect(() => { setPayload = (v) => React.startTransition(() => setPayload_(v)); }, [setPayload_]); // Re-fetch/render on client side navigation React.useEffect(() => { return listenNavigation(() => fetchRscPayload()); }, []); return payload.root; } // Re-fetch RSC and trigger re-rendering async function fetchRscPayload() { const renderRequest = createRscRenderRequest(globalThis.location.href); const payload = await createFromFetch(fetch(renderRequest)); setPayload(payload); } // Register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet(); const renderRequest = createRscRenderRequest(globalThis.location.href, { id, body: await encodeReply(args, { temporaryReferences }), }); const payload = await createFromFetch(fetch(renderRequest), { temporaryReferences, }); setPayload(payload); const { ok, data } = payload.returnValue!; if (!ok) throw data; return data; }); // Hydration const browserRoot = ( ); if ("__NO_HYDRATE" in globalThis) { createRoot(document).render(browserRoot); } else { hydrateRoot(document, browserRoot, { formState: initialPayload.formState, }); } // Implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { import.meta.hot.on("rsc:update", () => { fetchRscPayload(); }); } } // A little helper to setup events interception for client side navigation function listenNavigation(onNavigation: () => void) { globalThis.addEventListener("popstate", onNavigation); const oldPushState = globalThis.history.pushState; globalThis.history.pushState = function (...args) { const res = oldPushState.apply(this, args); onNavigation(); return res; }; const oldReplaceState = globalThis.history.replaceState; globalThis.history.replaceState = function (...args) { const res = oldReplaceState.apply(this, args); onNavigation(); return res; }; function onClick(e: MouseEvent) { const link = (e.target as Element).closest("a"); if ( link && link instanceof HTMLAnchorElement && link.href && (!link.target || link.target === "_self") && link.origin === location.origin && !link.hasAttribute("download") && e.button === 0 && // left clicks only !e.metaKey && // open in new tab (mac) !e.ctrlKey && // open in new tab (windows) !e.altKey && // download !e.shiftKey && !e.defaultPrevented ) { e.preventDefault(); history.pushState(null, "", link.href); } } document.addEventListener("click", onClick); return () => { document.removeEventListener("click", onClick); globalThis.removeEventListener("popstate", onNavigation); globalThis.history.pushState = oldPushState; globalThis.history.replaceState = oldReplaceState; }; } // eslint-disable-next-line unicorn/prefer-top-level-await main(); ``` ```tsx [app/framework/entry.rsc.tsx] import { renderToReadableStream, createTemporaryReferenceSet, decodeReply, loadServerAction, decodeAction, decodeFormState, } from "@vitejs/plugin-rsc/rsc"; import type { ReactFormState } from "react-dom/client"; import { Root } from "../root.tsx"; import { parseRenderRequest } from "./request.tsx"; // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. export type RscPayload = { // this demo renders/serializes/deserializes entire root html element // but this mechanism can be changed to render/fetch different parts of components // based on your own route conventions. root: React.ReactNode; // Server action return value of non-progressive enhancement case returnValue?: { ok: boolean; data: unknown }; // Server action form state (e.g. useActionState) of progressive enhancement case formState?: ReactFormState; }; // The plugin by default assumes `rsc` entry having default export of request handler. // however, how server entries are executed can be customized by registering own server handler. export default async function handler(request: Request): Promise { // Differentiate RSC, SSR, action, etc. const renderRequest = parseRenderRequest(request); request = renderRequest.request; // Handle server function request let returnValue: RscPayload["returnValue"] | undefined; let formState: ReactFormState | undefined; let temporaryReferences: unknown | undefined; let actionStatus: number | undefined; if (renderRequest.isAction === true) { if (renderRequest.actionId) { // Action is called via `ReactClient.setServerCallback`. const contentType = request.headers.get("content-type"); const body = contentType?.startsWith("multipart/form-data") ? await request.formData() : await request.text(); temporaryReferences = createTemporaryReferenceSet(); const args = await decodeReply(body, { temporaryReferences }); const action = await loadServerAction(renderRequest.actionId); try { // eslint-disable-next-line prefer-spread const data = await action.apply(null, args); returnValue = { ok: true, data }; } catch (error_) { returnValue = { ok: false, data: error_ }; actionStatus = 500; } } else { // Otherwise server function is called via `
` // before hydration (e.g. when JavaScript is disabled). // aka progressive enhancement. const formData = await request.formData(); const decodedAction = await decodeAction(formData); try { const result = await decodedAction(); formState = await decodeFormState(result, formData); } catch { // there's no single general obvious way to surface this error, // so explicitly return classic 500 response. return new Response("Internal Server Error: server action failed", { status: 500, }); } } } // Serialization from React VDOM tree to RSC stream. // We render RSC stream after handling server function request // so that new render reflects updated state from server function call // to achieve single round trip to mutate and fetch from server. const rscPayload: RscPayload = { root: , formState, returnValue, }; const rscOptions = { temporaryReferences }; const rscStream = renderToReadableStream(rscPayload, rscOptions); // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { status: actionStatus, headers: { "content-type": "text/x-component;charset=utf-8", }, }); } // Delegate to SSR environment for HTML rendering. // The plugin provides `loadModule` helper to allow loading SSR environment entry module // in RSC environment. however this can be customized by implementing own runtime communication // e.g. `@cloudflare/vite-plugin`'s service binding. const ssrEntryModule = await import.meta.viteRsc.loadModule( "ssr", "index" ); const ssrResult = await ssrEntryModule.renderHTML(rscStream, { formState, // Allow quick simulation of JavaScript disabled browser debugNoJS: renderRequest.url.searchParams.has("__nojs"), }); // Respond HTML return new Response(ssrResult.stream, { status: ssrResult.status, headers: { "Content-Type": "text/html", }, }); } if (import.meta.hot) { import.meta.hot.accept(); } ``` ```tsx [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( "rsc", "index" ); return rscEntryModule.default(request); }, }; export async function renderHTML( rscStream: ReadableStream, options: { formState?: ReactFormState; nonce?: string; debugNoJS?: boolean; } ): Promise<{ stream: ReadableStream; status?: number }> { // Duplicate one RSC stream into two. // - one for SSR (ReactClient.createFromReadableStream below) // - another for browser hydration payload by injecting . const [rscStream1, rscStream2] = rscStream.tee(); // Deserialize RSC stream back to React VDOM let payload: Promise | undefined; function SsrRoot() { // Deserialization needs to be kicked off inside ReactDOMServer context // for ReactDOMServer preinit/preloading to work payload ??= createFromReadableStream(rscStream1); return React.use(payload).root; } // Render HTML (traditional SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); let htmlStream: ReadableStream; let status: number | undefined; try { htmlStream = await renderToReadableStream(, { 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( , { bootstrapScriptContent: `self.__NO_HYDRATE=1;` + (options?.debugNoJS ? "" : bootstrapScriptContent), nonce: options?.nonce, } ); } let responseStream: ReadableStream = htmlStream; if (!options?.debugNoJS) { // Initial RSC stream is injected in HTML stream as // using utility made by devongovett https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, }) ); } return { stream: responseStream, status }; } ``` ```tsx [app/framework/error-boundary.tsx] "use client"; import React from "react"; // Minimal ErrorBoundary example to handle errors globally on browser export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { return {props.children}; } // https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx // https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary class ErrorBoundary extends React.Component<{ children?: React.ReactNode; errorComponent: React.FC<{ error: Error; reset: () => void; }>; }> { override state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { return { error }; } reset = () => { this.setState({ error: null }); }; override render() { const error = this.state.error; if (error) { return ; } return this.props.children; } } // https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 // https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { return ( Unexpected Error

Caught an unexpected error

          Error:{" "}
          {import.meta.env.DEV && "message" in props.error ? props.error.message : "(Unknown)"}
        
); } ``` ```tsx [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 服务器组件(RSC)。它包括服务器组件、客户端组件、服务器动作和流式 SSR。 ## 概述 #### **SSR 入口** 处理传入请求并将 React 组件渲染为 HTML #### **根组件** 作为服务器组件定义页面结构 #### **客户端组件** 使用 `"use client"` 指令实现交互部分 ## 1. SSR 入口 ```tsx [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( "rsc", "index" ); return rscEntryModule.default(request); }, }; export async function renderHTML( rscStream: ReadableStream, options: { formState?: ReactFormState; nonce?: string; debugNoJS?: boolean; } ): Promise<{ stream: ReadableStream; status?: number }> { // 将一个 RSC 流复制成两个流。 // - 一个用于 SSR(下方的 ReactClient.createFromReadableStream) // - 另一个用于通过注入 实现浏览器水合 const [rscStream1, rscStream2] = rscStream.tee(); // 将 RSC 流反序列化为 React 虚拟 DOM let payload: Promise | undefined; function SsrRoot() { // 反序列化需要在 ReactDOMServer 上下文中启动, // 以便 ReactDOMServer 的预初始化/预加载生效 payload ??= createFromReadableStream(rscStream1); return React.use(payload).root; } // 渲染 HTML(传统 SSR) const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); let htmlStream: ReadableStream; let status: number | undefined; try { htmlStream = await renderToReadableStream(, { bootstrapScriptContent: options?.debugNoJS ? undefined : bootstrapScriptContent, nonce: options?.nonce, formState: options?.formState, }); } catch { // 回退渲染一个空壳,在浏览器纯 CSR 运行, // 该过程能重放服务器组件错误并触发错误边界。 status = 500; htmlStream = await renderToReadableStream( , { bootstrapScriptContent: `self.__NO_HYDRATE=1;` + (options?.debugNoJS ? "" : bootstrapScriptContent), nonce: options?.nonce, } ); } let responseStream: ReadableStream = htmlStream; if (!options?.debugNoJS) { // 初始 RSC 流作为 注入到 HTML 流中, // 使用 devongovett 开发的工具 https://github.com/devongovett/rsc-html-stream responseStream = responseStream.pipeThrough( injectRSCPayload(rscStream2, { nonce: options?.nonce, }) ); } return { stream: responseStream, status }; } ``` SSR 入口处理渲染流程。它加载 RSC 入口模块,复制 RSC 流(一份用于 SSR,一份用于水合),将流反序列化回 React 虚拟 DOM 并渲染为 HTML。RSC 负载通过注入到 HTML 中实现客户端水合。 ## 2. 根服务器组件 ```tsx [app/root.tsx] import "./index.css"; // css 导入会自动注入导出的服务器组件中 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 ( {/* eslint-disable-next-line unicorn/text-encoding-identifier-case */} Nitro + Vite + RSC ); } function App(props: { url: URL }) { return (

Vite + RSC + Nitro

请求 URL: {props.url?.href}
  • 编辑 src/client.tsx 来测试客户端 HMR。
  • 编辑 src/root.tsx 来测试服务器端 HMR。
  • 访问{" "} _.rsc {" "} 查看 RSC 流负载。
  • 访问{" "} ?__nojs {" "} 测试在不启用 JS 的情况下的服务器动作。
); } ``` 服务器组件仅在服务器端运行。它们可以直接导入 CSS,使用服务器端数据并调用服务器动作。`ClientCounter` 组件被导入但运行在客户端,因为它带有 `"use client"` 指令。 ## 3. 客户端组件 ```tsx [app/client.tsx] "use client"; import React from "react"; export function ClientCounter() { const [count, setCount] = React.useState(0); return ; } ``` `"use client"` 指令将其标记为客户端组件。它在浏览器中水合并处理交互状态。服务器组件可以导入和渲染客户端组件,但客户端组件不能导入服务器组件。 ## 了解更多 - [React 服务器组件](https://react.dev/reference/rsc/server-components)