بازگشت به وبلاگ
۱۴۰۵ آبان

پشت صحنه React Server Components؛ از اجرای سرور تا تعامل در مرورگر

وقتی یک صفحه React باز می‌شود، کدام بخش روی سرور اجرا می‌شود و چه چیزی به مرورگر می‌رسد؟ در این راهنمای معماری، مسیر اجرای React Server Components، مرز Server و Client، نقش RSC Payload، انتقال داده، Streaming و طراحی یک صفحه واقعی در Next.j

  • React
  • RSC
  • React Architecture
  • Next.js
  • Rendering
  • Frontend
  • Web Performance
  • پشت صحنه React Server Components؛ از اجرای سرور تا تعامل در مرورگر

    پشت صحنه React Server Components؛ از اجرای سرور تا تعامل در مرورگر

    تصور کن کاربر وارد صفحه جزئیات یک محصول می‌شود. عنوان، تصویر، قیمت و توضیحات محصول باید نمایش داده شوند؛ موجودی باید از دیتابیس خوانده شود؛ دکمه افزودن به سبد خرید باید کار کند و فیلترهای صفحه نیز باید به تعامل کاربر پاسخ دهند.

    یک سؤال معماری مهم وجود دارد:

    آیا واقعاً لازم است تمام این قابلیت‌ها در مرورگر اجرا شوند؟

    React Server Components یا RSC پاسخی متفاوت به این سؤال می‌دهد. به‌جای اینکه کل رابط کاربری را به یک محیط اجرایی وابسته کنیم، می‌توانیم مسئولیت‌ها را بین سرور و مرورگر تقسیم کنیم.

    اما این تقسیم مسئولیت دقیقاً چگونه اتفاق می‌افتد؟ چه چیزی در سرور اجرا می‌شود؟ چه چیزی به مرورگر می‌رسد؟ و React چگونه این دو محیط را به یک رابط کاربری واحد تبدیل می‌کند؟

    برای پاسخ، مسیر یک درخواست واقعی را دنبال می‌کنیم.

    ۱. قبل از هر چیز: دو محیط، دو مجموعه قابلیت

    مرورگر و سرور دو محیط اجرایی متفاوت هستند.

    مرورگر به قابلیت‌هایی مثل window، localStorage، DOM، Event Handlerها و تعامل مستقیم کاربر دسترسی دارد.

    سرور می‌تواند به دیتابیس، فایل‌های سیستم، متغیرهای محیطی محرمانه و سرویس‌های داخلی دسترسی داشته باشد.

    مشکل زمانی ایجاد می‌شود که تلاش کنیم تمام مسئولیت‌ها را در یک محیط قرار دهیم.

    برای مثال، اگر یک صفحه فقط اطلاعات محصول را نمایش می‌دهد، چرا باید کد اتصال به دیتابیس یا کتابخانه‌ای که فقط برای پردازش داده در سرور استفاده می‌شود، به مرورگر ارسال شود؟

    از طرف دیگر، اگر کاربر روی دکمه افزودن به سبد خرید کلیک می‌کند، این تعامل به منطق سمت کلاینت نیاز دارد.

    RSC این دو محیط را به دو بخش قابل ترکیب تقسیم می‌کند:

    • Server Components: برای اجرای منطق و رندر در محیط سرور.
    • Client Components: برای قابلیت‌هایی که به محیط مرورگر و تعامل کاربر نیاز دارند.

    این تقسیم‌بندی را می‌توان یک Network Boundary در درخت کامپوننت‌ها در نظر گرفت؛ مرزی که مشخص می‌کند کدام بخش از برنامه در کدام محیط اجرا شود.

    ۲. وقتی کاربر یک صفحه را باز می‌کند چه اتفاقی می‌افتد؟

    فرض کنیم کاربر وارد مسیر زیر می‌شود:

    /products/keyboard
    

    صفحه به اطلاعات زیر نیاز دارد:

    • مشخصات محصول از دیتابیس
    • اطلاعات فروشنده
    • قیمت و موجودی
    • دکمه افزودن به سبد خرید
    • انتخاب تعداد محصول

    در معماری RSC، همه این مسئولیت‌ها لزوماً در مرورگر انجام نمی‌شوند.

    یک تقسیم‌بندی مناسب می‌تواند این‌گونه باشد:

    ProductPage (Server)
    │
    ├── ProductInformation (Server)
    │
    ├── SellerInformation (Server)
    │
    ├── ProductPrice (Server)
    │
    ├── QuantitySelector (Client)
    │
    └── AddToCartButton (Client)
    

    در این ساختار، اطلاعات محصول در سرور دریافت می‌شوند؛ اما انتخاب تعداد و افزودن به سبد خرید در مرورگر مدیریت می‌شوند.

    این موضوع فقط یک تغییر در نام‌گذاری کامپوننت‌ها نیست؛ بلکه روی محل اجرای کد، حجم JavaScript ارسالی و نحوه دریافت داده تأثیر می‌گذارد.

    ۳. Server Component دقیقاً کجا اجرا می‌شود؟

    Server Component نوعی کامپوننت React است که پیش از ارسال خروجی به Client، در محیط جداگانه‌ای اجرا می‌شود.

    این محیط می‌تواند:

    • هنگام Build روی سیستم CI باشد.
    • هنگام هر درخواست روی Web Server اجرا شود.

    بنابراین Server Component الزاماً به معنی اجرای مجدد کامپوننت در هر درخواست نیست. زمان اجرا به معماری Framework و نحوه استفاده از داده‌ها بستگی دارد.

    مثال:

    import { db } from "@/lib/db";
    
    type Props = {
      productId: string;
    };
    
    export default async function ProductInformation({
      productId,
    }: Props) {
      const product = await db.product.findUnique({
        where: {
          id: productId,
        },
        select: {
          name: true,
          description: true,
          price: true,
        },
      });
    
      if (!product) {
        return <p>Product not found.</p>;
      }
    
      return (
        <section>
          <h1>{product.name}</h1>
          <p>{product.description}</p>
          <strong>${product.price}</strong>
        </section>
      );
    }
    

    چند نکته در این مثال وجود دارد:

    1. کامپوننت می‌تواند async باشد.
    2. اطلاعات مستقیماً از دیتابیس دریافت می‌شوند.
    3. نیازی به useEffect برای دریافت اولیه اطلاعات نیست.
    4. کد اتصال به دیتابیس بخشی از Client Bundle نخواهد بود.

    نکته مهم: RSC به‌خودی‌خود Database، ORM یا Backend ایجاد نمی‌کند. فقط امکان اجرای کامپوننت در محیط سرور را فراهم می‌کند؛ زیرساخت دسترسی به داده را باید خودتان فراهم کنید.

    ۴. پس مرورگر چه چیزی دریافت می‌کند؟

    اینجا به یکی از مفاهیم اصلی RSC می‌رسیم: RSC Payload.

    وقتی Server Components اجرا می‌شوند، React نتیجه آن‌ها را در یک قالب داده‌ای اختصاصی آماده می‌کند.

    این خروجی صرفاً HTML معمولی نیست.

    RSC Payload اطلاعاتی برای بازسازی و هماهنگ‌سازی درخت کامپوننت‌ها در اختیار Client قرار می‌دهد؛ از جمله:

    • نتیجه رندر Server Components
    • محل قرارگیری Client Components
    • Reference مربوط به JavaScript کامپوننت‌های Client
    • Props موردنیاز برای Client Components

    به زبان ساده، RSC Payload به React می‌گوید چه بخش‌هایی از رابط کاربری در سرور آماده شده‌اند و کدام بخش‌ها باید با کد کلاینت تکمیل شوند.

    تفاوت سه خروجی مهم

    مفهوممسئولیت
    HTMLنمایش اولیه محتوا در مرورگر
    RSC Payloadانتقال اطلاعات درخت Server/Client برای React
    Client JavaScriptاجرای تعاملات و قابلیت‌های سمت مرورگر

    این سه مورد را نباید با یکدیگر یکی دانست.

    ۵. First Load؛ از پاسخ سرور تا Hydration

    در Next.js App Router، بارگذاری اولیه صفحه از چند مرحله تشکیل می‌شود.

    User Request
         |
         v
    Next.js Server
         |
         v
    Render Server Components
         |
         v
    Generate RSC Payload
         |
         v
    Pre-render HTML
         |
         v
    Browser Receives Response
         |
         +------> Display HTML
         |
         +------> Reconcile RSC Tree
         |
         +------> Load Client JavaScript
                        |
                        v
                     Hydration
                        |
                        v
                  Interactive UI
    

    این فرآیند را مرحله‌به‌مرحله بررسی کنیم.

    مرحله اول: اجرای Server Components

    سرور کامپوننت‌هایی را که در بخش Server قرار دارند اجرا می‌کند و داده‌های موردنیاز را دریافت می‌کند.

    مرحله دوم: تولید RSC Payload

    React اطلاعات مربوط به درخت رندرشده و محل قرارگیری Client Components را آماده می‌کند.

    مرحله سوم: تولید HTML اولیه

    در Next.js، HTML اولیه از ترکیب خروجی Server Components و پیش‌رندر Client Components تولید می‌شود.

    مرحله چهارم: نمایش صفحه

    مرورگر می‌تواند HTML را نمایش دهد؛ حتی پیش از اینکه تعاملات کلاینت آماده باشند.

    مرحله پنجم: Hydration

    JavaScript مربوط به Client Components بارگذاری و اجرا می‌شود و React Event Handlerها را به DOM متصل می‌کند.

    در نتیجه صفحه از یک خروجی اولیه قابل مشاهده به یک رابط کاربری تعاملی تبدیل می‌شود.

    نکته مهم: Client Componentها در بارگذاری اولیه Next.js می‌توانند برای تولید HTML روی سرور نیز رندر شوند؛ بنابراین Client Component به معنی «فقط اجرا در مرورگر» نیست.

    ۶. use client؛ نقطه‌ای که مرز معماری تغییر می‌کند

    فرض کنیم صفحه محصول را به شکل زیر نوشته‌ایم:

    import ProductInformation from "./product-information";
    import AddToCartButton from "./add-to-cart-button";
    
    export default async function ProductPage() {
      const product = await getProduct();
    
      return (
        <main>
          <ProductInformation product={product} />
    
          <AddToCartButton productId={product.id} />
        </main>
      );
    }
    

    در Next.js App Router این صفحه به‌صورت پیش‌فرض Server Component است.

    اما فایل دکمه به تعامل نیاز دارد:

    "use client";
    
    import { useState } from "react";
    
    type Props = {
      productId: string;
    };
    
    export default function AddToCartButton({
      productId,
    }: Props) {
      const [added, setAdded] = useState(false);
    
      return (
        <button onClick={() => setAdded(true)}>
          {added ? "Added to cart" : "Add to cart"}
        </button>
      );
    }
    

    عبارت "use client" یک مرز در Client Module Graph تعریف می‌کند.

    یعنی از این نقطه به بعد، Importهای آن فایل و وابستگی‌های Client آن در گراف ماژول سمت کلاینت قرار می‌گیرند.

    به همین دلیل، لازم نیست در تک‌تک فایل‌های زیرمجموعه دوباره "use client" بنویسیم.

    یک اشتباه رایج در طراحی

    این ساختار را در نظر بگیر:

    Dashboard (Client)
    │
    ├── Header
    ├── Sidebar
    ├── Statistics
    ├── Chart
    ├── Search
    └── Filters
    

    اگر کل Dashboard را Client Component کنیم، بخش‌های غیرتعاملی هم وارد Client Module Graph می‌شوند.

    ساختار هدفمندتر:

    Dashboard (Server)
    │
    ├── Header (Server)
    ├── Sidebar (Server)
    ├── Statistics (Server)
    ├── Chart (Client)
    ├── Search (Client)
    └── Filters (Client)
    

    هدف این نیست که تعداد Client Components را به صفر برسانیم؛ هدف این است که مرز کلاینت را فقط جایی قرار دهیم که واقعاً به آن نیاز داریم.

    ۷. یک قانون مهم: Server Component را مستقیماً Import نکنید

    فرض کنیم یک Modal در سمت کلاینت داریم و می‌خواهیم داخل آن اطلاعاتی را نمایش دهیم که از دیتابیس دریافت می‌شوند.

    روش اشتباه:

    "use client";
    
    import CartContent from "./cart-content";
    
    export default function CartModal() {
      return (
        <div>
          <CartContent />
        </div>
      );
    }
    

    اگر CartContent یک Server Component باشد، Import کردن آن از یک Client Module باعث نمی‌شود کدش در سرور باقی بماند.

    راه درست استفاده از Composition است.

    کامپوننت Modal:

    "use client";
    
    import { useState } from "react";
    
    type Props = {
      children: React.ReactNode;
    };
    
    export default function CartModal({ children }: Props) {
      const [open, setOpen] = useState(false);
    
      return (
        <>
          <button onClick={() => setOpen(true)}>
            Open cart
          </button>
    
          {open && (
            <section>
              {children}
    
              <button onClick={() => setOpen(false)}>
                Close
              </button>
            </section>
          )}
        </>
      );
    }
    

    کامپوننت سبد خرید:

    import { db } from "@/lib/db";
    
    export default async function CartContent() {
      const items = await db.cartItem.findMany();
    
      return (
        <ul>
          {items.map((item) => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      );
    }
    

    صفحه والد:

    import CartModal from "./cart-modal";
    import CartContent from "./cart-content";
    
    export default function Page() {
      return (
        <CartModal>
          <CartContent />
        </CartModal>
      );
    }
    

    در این روش، Server Component در والد Server رندر می‌شود و نتیجه آن به‌عنوان children به Client Component داده می‌شود.

    این الگو یکی از مهم‌ترین ابزارهای طراحی معماری RSC است.

    ۸. Props از مرز سرور عبور می‌کنند؛ اما هر داده‌ای نه

    وقتی یک Server Component اطلاعاتی را به Client Component می‌دهد، باید به قوانین Serialization مربوط به React توجه کنیم.

    مثال ساده:

    <ProductActions
      product={{
        id: product.id,
        name: product.name,
        price: product.price,
      }}
    />
    

    این نوع داده‌ها برای انتقال مناسب‌اند.

    اما نباید فرض کنیم هر شیء JavaScript را می‌توان بدون محدودیت از مرز Server و Client عبور داد.

    برای مثال، ارسال یک اتصال دیتابیس، نمونه کلاس دلخواه یا شیء حاوی منابع سرور به Client Component الگوی درستی نیست.

    بهتر است فقط داده‌هایی را منتقل کنید که واقعاً برای رابط کاربری لازم هستند.

    طراحی بهتر Data Contract

    به‌جای ارسال کل رکورد دیتابیس:

    <ClientProduct product={databaseProduct} />
    

    یک مدل محدود و مشخص تعریف کنید:

    type ProductViewModel = {
      id: string;
      name: string;
      price: number;
      available: boolean;
    };
    
    const productViewModel: ProductViewModel = {
      id: product.id,
      name: product.name,
      price: product.price,
      available: product.stock > 0,
    };
    

    این کار علاوه بر کنترل داده‌های ارسالی، وابستگی رابط کاربری به ساختار داخلی دیتابیس را نیز کاهش می‌دهد.

    ۹. Streaming؛ لازم نیست همه‌چیز هم‌زمان آماده شود

    در یک صفحه واقعی ممکن است اطلاعات اصلی سریع دریافت شوند، اما یک بخش جانبی به زمان بیشتری نیاز داشته باشد.

    برای مثال:

    • اطلاعات محصول: سریع
    • مشخصات فروشنده: سریع
    • نظرات کاربران: کندتر
    • پیشنهادهای مرتبط: کندتر

    آیا باید برای دریافت تمام این اطلاعات منتظر بمانیم؟

    نه لزوماً.

    با Streaming و Suspense می‌توان بخش‌های آماده را زودتر نمایش داد و برای بخش‌های در حال دریافت داده، مرزهای انتظار تعریف کرد.

    import { Suspense } from "react";
    
    import ProductDetails from "./product-details";
    import ProductReviews from "./product-reviews";
    import RelatedProducts from "./related-products";
    
    export default async function ProductPage() {
      const product = await getProduct();
    
      return (
        <main>
          <ProductDetails product={product} />
    
          <Suspense fallback={<p>Loading reviews...</p>}>
            <ProductReviews productId={product.id} />
          </Suspense>
    
          <Suspense fallback={<p>Loading recommendations...</p>}>
            <RelatedProducts productId={product.id} />
          </Suspense>
        </main>
      );
    }
    

    در این ساختار، بخش‌های مستقل می‌توانند با زمان‌بندی متفاوت آماده شوند.

    البته برای استفاده مؤثر از Streaming باید به محل قرارگیری Suspense، وابستگی داده‌ها و قابلیت‌های Framework توجه کرد. اضافه کردن Suspense به‌تنهایی تضمین‌کننده بهبود عملکرد نیست.

    ۱۰. یک نکته ظریف: RSC و Server Functions یکی نیستند

    در پروژه‌های React ممکن است با این دو عبارت روبه‌رو شوید:

    "use client";
    

    و:

    "use server";
    

    این دو Directive کار یکسانی انجام نمی‌دهند.

    "use client" مرز Client Module Graph را مشخص می‌کند.

    "use server" برای تعریف Server Function استفاده می‌شود؛ تابعی که از Client قابل فراخوانی است اما در سرور اجرا می‌شود.

    برای مثال:

    "use server";
    
    import { db } from "@/lib/db";
    
    export async function addToCart(productId: string) {
      // Authenticate the user.
      // Validate the product.
      // Check permissions.
      // Update the database.
    
      await db.cartItem.create({
        data: {
          productId,
        },
      });
    }
    

    این تابع می‌تواند از یک Client Component فراخوانی شود.

    اما نکته امنیتی مهمی وجود دارد: Server Function را نباید یک تابع داخلی و غیرقابل‌دسترسی تصور کرد. درخواست‌های ورودی آن باید مانند ورودی یک Endpoint سمت سرور اعتبارسنجی و مجوزسنجی شوند.

    همچنین "use server" یک Server Component ایجاد نمی‌کند.

    ۱۱. RSC چه چیزی را حل می‌کند و چه چیزی را نه؟

    RSC ابزار جادویی برای سریع‌تر کردن تمام پروژه‌های React نیست.

    مسئلهنقش RSC
    ارسال کد غیرضروری به مرورگرکمک به کاهش Client JavaScript
    دریافت مستقیم داده در سرورفراهم کردن امکان آن
    دسترسی به Secretهانگه‌داشتن منطق حساس در محیط سرور
    نمایش اولیه محتوامی‌تواند در کنار SSR استفاده شود
    تعامل کاربرهمچنان به Client Components نیاز دارد
    مدیریت State کلاینتجایگزین ابزارهای State Management نیست
    Real-time Dataبه راهکارهای جداگانه نیاز دارد
    Authorizationباید به‌صورت صریح پیاده‌سازی شود

    یک Server Component می‌تواند حجم JavaScript سمت کلاینت را کاهش دهد، اما این موضوع به ساختار پروژه، وابستگی‌ها و تعداد Client Components بستگی دارد.

    همچنین انتقال پردازش به سرور می‌تواند هزینه شبکه، بار سرور یا تأخیر دریافت داده را افزایش دهد. پس باید عملکرد واقعی را اندازه‌گیری کرد.

    ۱۲. در Next.js از کجا شروع کنیم؟

    در Next.js App Router، page.tsx و layout.tsx به‌صورت پیش‌فرض Server Component هستند.

    یک ساختار ساده:

    app/
    └── products/
        ├── page.tsx
        └── components/
            ├── product-details.tsx
            ├── product-reviews.tsx
            ├── quantity-selector.tsx
            └── add-to-cart-button.tsx
    
    lib/
    ├── db.ts
    └── products.ts
    

    نمونه صفحه:

    import { getProduct } from "@/lib/products";
    
    import ProductDetails from "./components/product-details";
    import QuantitySelector from "./components/quantity-selector";
    import AddToCartButton from "./components/add-to-cart-button";
    
    export default async function ProductPage({
      params,
    }: {
      params: Promise<{ id: string }>;
    }) {
      const { id } = await params;
      const product = await getProduct(id);
    
      return (
        <main>
          <ProductDetails product={product} />
    
          <QuantitySelector />
    
          <AddToCartButton productId={product.id} />
        </main>
      );
    }
    

    در این مثال:

    • صفحه، Server Component است.
    • اطلاعات محصول در سرور دریافت می‌شود.
    • انتخاب تعداد، Client Component است.
    • افزودن به سبد خرید، Client Component است.
    • داده لازم برای تعامل به‌صورت محدود به کلاینت منتقل می‌شود.

    برای اطلاعات بیشتر درباره جزئیات پیاده‌سازی و رفتار نسخه‌های مختلف Next.js، مستندات همان نسخه را بررسی کنید؛ به‌خصوص رفتار Cache و Data Fetching ممکن است با نسخه Framework تغییر کند.

    ۱۳. چک‌لیست معماری برای یک پروژه واقعی

    قبل از اینکه یک فایل را با "use client" علامت‌گذاری کنید، این سؤال‌ها را از خودتان بپرسید:

    • آیا این کامپوننت واقعاً به State نیاز دارد؟
    • آیا Event Handler دارد؟
    • آیا از Browser API استفاده می‌کند؟
    • آیا می‌تواند در سرور باقی بماند؟
    • آیا فقط یک فرزند کوچک آن به تعامل نیاز دارد؟
    • آیا Props ارسالی به Client واقعاً ضروری هستند؟
    • آیا اطلاعات محرمانه یا داده اضافی در حال عبور از مرز هستند؟
    • آیا این بخش باید با Streaming نمایش داده شود؟

    اگر فقط یک بخش کوچک به تعامل نیاز دارد، بهتر است همان بخش را Client کنید، نه کل صفحه را.

    نتیجه: مرز سرور و کلاینت را آگاهانه طراحی کنید

    React Server Components صرفاً یک روش جدید برای نوشتن کامپوننت نیستند؛ آن‌ها روی معماری، Data Flow، مرزبندی کد و نحوه ارسال JavaScript به مرورگر تأثیر می‌گذارند.

    برای استفاده درست از RSC باید به‌جای پرسیدن اینکه «این کامپوننت Server است یا Client؟»، سؤال دقیق‌تری بپرسیم:

    این بخش از برنامه به کدام محیط نیاز دارد و چه چیزی واقعاً باید از مرز شبکه عبور کند؟

    این تغییر نگاه، پایه طراحی معماری مناسب در پروژه‌های مدرن React است.


    منابع فنی

    • React — Server Components
    • React — Directives
    • React — Server Functions
    • Next.js — Server and Client Components
    • Next.js — App Router

    این مقاله با تمرکز بر معماری React و پیاده‌سازی آن در Next.js App Router نوشته شده است.

    اشتراک‌گذاریاشتراک در ایکساشتراک در تلگرام

    آخرین به‌روزرسانی: ۱۴۰۵ آبان

    ا

    امیر اسماعیل زاده

    توسعه دهنده فرانت اند

    توسعه‌دهنده فرانت‌اند با بیش از 3 سال تجربه در ساخت وب‌سایت‌ها و اپلیکیشن‌های وب مدرن و واکنش‌گرا، با تمرکز بر React، Next.js، بهینه‌سازی عملکرد و تجربه کاربری.

    پست بعدیReact Compiler چیست؟ آیا دیگر به useMemo و useCallback نیاز نداریم؟

    پست‌های مرتبط

    • React Compiler چیست؟ آیا دیگر به useMemo و useCallback نیاز نداریم؟۱۴۰۵ آبان
    • TanStack Redact چیست؟ جایگزینی کوچک‌تر برای React با تمرکز بر Performance۱۴۰۵ آبان
    EN