React 19 與 Server Components 深度實踐

前端工程

我們團隊踩過的第一個 RSC 坑

去年把一個 CRA 老專案往 Next.js App Router 遷,我信心滿滿地把整個頁面標成非同步組件,直接在組件裡 await fetch。本地跑得好好的,一上預發就白屏。查了半小時,才發現我把一個用了 onClick 的按鈕連同它的父組件一起留在了服務端——服務端組件裡根本不存在事件這個概念。

那次事故讓我明白:RSC 不是「換個寫法」,而是把「哪段程式碼跑在哪台機器上」重新想了一遍。下面把這兩年踩的坑和驗證過的做法攤開講。


一、React 19 到底改了什麼

先說純粹的客戶端側變化,這些和 RSC 無關,但遷移時一定會碰到。

use():終於能讀 Promise 和 Context 了

use() 可以在渲染中直接 await 一個 Promise,也能讀 Context,而且支援在條件語句裡呼叫(這是和 useContext 最大的區別)。

import { use } from "react";

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise); // 直接拿到結果,不用 useEffect
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}

Actions:把「提交→等待→刷新」變成一等公民

以前要自己管 isPendinguseState、表單重置。現在 useActionState + 服務端 Action 一把梭:

"use server";
async function subscribe(prevState: { ok: boolean }, formData: FormData) {
  const email = formData.get("email");
  // 直接跑在服務端,能碰資料庫/密鑰
  await db.users.subscribe(String(email));
  return { ok: true };
}
"use client";
import { useActionState } from "react";

function Form() {
  const [state, formAction, pending] = useActionState(subscribe, { ok: false });
  return (
    <form action={formAction}>
      <input name="email" />
      <button disabled={pending}>{pending ? "提交中…" : "訂閱"}</button>
      {state.ok && <p>已訂閱</p>}
    </form>
  );
}

useOptimistic:樂觀更新不再手寫

const [optimistic, addOptimistic] = useOptimistic(
  messages,
  (cur, next: string) => [...cur, { text: next, sending: true }]
);

兩個小但爽的改動

  • ref 可以作為普通 prop 傳遞,不再需要 forwardRef(老 API 仍可用)。
  • 原生 <form><meta><title><link> 可以直接寫在組件裡,React 會自動提升到 <head>

二、RSC 到底是什麼:運行模型

Server Component 是在建構時或服務端渲染的組件,它的程式碼不會進入瀏覽器 bundle。它回傳的不是 DOM,而是一種特殊的序列化格式(RSC Payload),由客戶端 React 拼接。

幾個反直覺的點:

  • 服務端組件裡可以 import 巨大的服務端-only 函式庫(比如直接連 DB 的驅動),因為這部分程式碼根本不下發。
  • 服務端組件沒有 useStateuseEffect、瀏覽器事件——它只在請求到達時跑一次。
  • 客戶端組件(標了 "use client")會被打包進 JS,在服務端也會先渲染出初始 HTML。

畫個心智模型:

請求 ──▶ 服務端組件樹(跑在 Node)
            │ 遇到 "use client" 邊界
            ▼
       序列化 RSC Payload ──▶ 瀏覽器
                               │ 客戶端組件 hydrate
                               ▼
                         可互動的頁面

三、'use client' 邊界怎麼劃

這是遷移時最費腦子的地方。原則只有一條:客戶端邊界要盡可能靠近葉子節點

// 服務端組件(預設)
import { db } from "@/server/db";
import LikeButton from "./LikeButton"; // 這是個客戶端組件

export default async function Post({ id }: { id: string }) {
  const post = await db.posts.find(id); // 直接查庫,安全
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton initial={post.likes} /> {/* 只有這個按鈕需要互動 */}
    </article>
  );
}
"use client";
export default function LikeButton({ initial }: { initial: number }) {
  const [likes, setLikes] = useState(initial);
  return <button onClick={() => setLikes(v => v + 1)}>👍 {likes}</button>;
}

常見錯誤:把整個頁面標成 "use client"。這樣你就退回到了純 CSR,RSC 的全部收益(更小的 bundle、直連資料源)都沒了。只在真正需要互動、瀏覽器 API、useState 的最小組件上加 "use client"


四、在服務端組件裡直接拿資料

不用再寫 getServerSideProps 那套。組件本身是 async 的:

export default async function Dashboard() {
  const [orders, users] = await Promise.all([
    fetch("https://api/internal/orders").then(r => r.json()),
    fetch("https://api/internal/users").then(r => r.json()),
  ]);
  return <Charts orders={orders} users={users} />;
}

注意:回傳給客戶端組件用的 props,必須是可序列化的——函式、Map、class 實例都不行。需要傳函式?把它留在客戶端組件裡定義。


五、流式渲染與 Suspense

大數據查詢慢,但我們不想讓整個頁面乾等。用 Suspense 把慢的部分「挖」出來單獨載入:

import { Suspense } from "react";

export default function Page() {
  return (
    <main>
      <h1>報表中心</h1>
      <Suspense fallback={<Skeleton />}>
        <SlowReport /> {/* 這個組件內部 await 慢查詢 */}
      </Suspense>
    </main>
  );
}

服務端會先吐出 <h1> 和骨架屏,等 SlowReport 的資料就緒,再把這個「洞」補上(流式)。用戶感知到的首屏快了很多。


六、快取:fetch 預設就被快取了

RSC 環境下,fetch 預設帶快取(等同於 cache: 'force-cache')。這在多數讀場景是好事,但要清楚怎麼關:

// 每次請求都拿最新
fetch(url, { cache: "no-store" });

// 帶時間視窗的重新驗證
fetch(url, { next: { revalidate: 60 } }); // 60 秒內用快取

需要跨請求復用、且不在 fetch 上的計算,用 React.cache()

import { cache } from "react";

export const getUser = cache(async (id: string) => {
  return db.users.find(id); // 同一次渲染裡多次呼叫只查一次庫
});

七、遷移真實專案的多個坑

  1. Context 只能從客戶端組件往下傳。在服務端組件裡定義 Context Provider 會報錯——把 Provider 放到客戶端邊界內。
  2. 事件處理器不能從服務端組件傳下去。需要 onClick?那一層必須是客戶端組件。
  3. window/document/localStorage 在服務端是 undefined。所有瀏覽器 API 的存取必須放在 "use client" 組件、useEffect 或事件裡。
  4. 第三方函式庫不一定相容。遇到「exports were not found」或水合報錯,先看它有沒有 "use client" 友善的版本,或把它包進你自己的客戶端組件裡再引入。
  5. 別在服務端組件裡寫 useState。一寫就編譯不過,提醒你邊界劃錯了。

八、什麼時候其實不該用 RSC

RSC 不是銀彈。以下場景純客戶端可能更合適:

  • 強互動、弱資料的工具型頁面(富文本編輯器、畫板、IDE 類)。
  • 純靜態、幾乎無後端依賴的營銷頁(用 SSG/靜態匯出更輕)。
  • 團隊對服務端渲染心智模型還不熟、又趕工期時,強上 RSC 容易寫出既慢又難維護的「四不像」。

常見問題

Q1:RSC 和 SSR 是一回事嗎?

不是。SSR 是把客戶端組件在服務端渲染成 HTML(程式碼仍會進 bundle)。RSC 是組件根本不進瀏覽器,運行在服務端。兩者常一起用:RSC 負責資料/結構,SSR 負責首屏 HTML。

Q2:客戶端組件的 props 為什麼不能傳函式?

因為 props 要經過 RSC Payload 序列化,函式無法序列化。需要行為?在客戶端組件內部自己定義,或透過 Actions 觸發服務端邏輯。

Q3:'use client' 寫在整個檔案頂部,那檔案裡所有組件都變客戶端了?

是的。一個檔案只能有一個邊界,"use client" 讓整個模組成為客戶端模組。所以盡量讓客戶端組件獨立成小檔案,服務端組件去 import 它們。

Q4:React 19 能直接用在老專案裡嗎?

客戶端特性(useuseOptimistic 等)升 React 版本即可。但要享受 RSC,需要配套的框架(Next.js App Router、或支援 RSC 的元框架)。裸 webpack 專案用不了 RSC。

Q5:水合(hydration)不匹配怎麼排查?

典型症狀是服務端渲染的文字和客戶端首次渲染不一致(如時間戳、亂數、toLocaleString)。把這類值在 useEffect 裡再賦值,或對「依賴客戶端的資訊」做骨架屏佔位。


工具推薦

折腾 RSC 和組件時,這幾個 ToolsKu 工具很好用:

  • 線上程式碼運行 — 快速驗證一段 TSX 片段的運行時行為
  • Mermaid 預覽 — 把客戶端/服務端組件樹畫成流程圖對齊團隊認知
  • JSON 格式化 — 看清楚從服務端組件往下傳的 props 到底序列化成了什麼

RSC 的本質,是把「程式碼運行在哪裡」這個被 SPA 時代藏起來的問題,重新擺回你面前。想清楚邊界,它就是你包體積的救星;想不清楚,它就是新的混亂源頭。

本站提供瀏覽器本地工具,免註冊即可試用 →

#React 19#Server Components#SSR#前端工程#Next.js