Route-based Splitting: ترکیب دقیق‌تر Code Splitting با Routing

در فصل پانزدهم، هنگام آشنایی اولیه با Routing، به‌طور مختصر دیدیم چگونه صفحات را lazy کنیم. حالا که در این فصل، Dynamic Import، lazy و اصول کلی‌تر Code Splitting را با جزئیات بیشتری دیدیم، در این درس به چند الگوی عملی‌تر در ترکیب این دو می‌پردازیم که در آن درس فرصت پرداختن به آن‌ها نبود.

یادآوری سریع ساختار پایه

jsx
const ProductsPage = lazy(() => import("./ProductsPage"));

const router = createBrowserRouter([
  { path: "/", element: <HomePage /> },
  { path: "/products", element: <ProductsPage /> },
]);

مسئله: کجا Suspense را قرار دهیم

یک تصمیم مهم، سطحی است که Suspense در آن قرار می‌گیرد. اگر یک Suspense واحد، دور کل RouterProvider باشد، با هر تغییر مسیر به یک صفحه lazy، تمام محتوای قبلی (حتی Layout مشترکی که در فصل پانزدهم ساختیم) ناپدید و با fallback جایگزین می‌شود:

jsx
// هر مسیر lazy، کل صفحه را با fallback جایگزین می‌کند
<Suspense fallback={<FullPageSpinner />}>
  <RouterProvider router={router} />
</Suspense>

راه‌حل دقیق‌تر، قرار دادن Suspense در سطح پایین‌تر، فقط دور Outlet است؛ تا Header و Footer که در فصل پانزدهم دیدیم موقعیت ثابتی در درخت دارند، در طول این بارگذاری، همچنان روی صفحه بمانند:

jsx
function Layout() {
  return (
    <div>
      <Header />
      <Suspense fallback={<p>در حال بارگذاری صفحه...</p>}>
        <Outlet />
      </Suspense>
      <Footer />
    </div>
  );
}

مشکل: فاصله زمانی بین کلیک و شروع دانلود

در حالت پیش‌فرض، دانلود فایل یک صفحه lazy، فقط وقتی شروع می‌شود که React واقعاً تلاش کند آن را Render کند؛ یعنی درست پس از کلیک کاربر روی لینک. این یعنی کاربر، حتی برای یک صفحه نسبتاً سبک، مدتی منتظر دانلود می‌ماند، درست در لحظه‌ای که انتظار واکنش فوری دارد.

راه‌حل: Preloading هنگام Hover

یک تکنیک رایج، شروع همان import()، نه در لحظه کلیک، بلکه زودتر؛ مثلاً وقتی ماوس کاربر روی یک لینک قرار می‌گیرد (Hover)، که معمولاً چند صد میلی‌ثانیه پیش از کلیک واقعی رخ می‌دهد:

jsx
function ProductsLink() {
  function handleMouseEnter() {
    import("./ProductsPage"); // شروع دانلود، پیش از کلیک
  }

  return (
    <Link to="/products" onMouseEnter={handleMouseEnter}>
      محصولات
    </Link>
  );
}

نکته مهم این است که فراخوانی دوباره import() برای همان مسیر، توسط خود مرورگر و ابزار بسته‌بندی، Cache و هم‌پوشانی می‌شود؛ یعنی وقتی بعداً lazy همان ماژول را درخواست کند، اگر از قبل در حال دانلود یا دانلود شده باشد، دوباره از صفر شروع نمی‌شود.

تصمیم‌گیری: کدام صفحات باید lazy باشند

همان‌طور که در درس قبل درباره تقسیم بیش‌ازحد گفتیم، این قانون اینجا هم صدق می‌کند: صفحه خانه یا صفحه ورود، که تقریباً همیشه اولین چیزی هستند که کاربر می‌بیند، معمولاً نباید lazy شوند؛ چون در این حالت، lazy کردن فقط یک تأخیر غیرضروری اضافه می‌کند. صفحاتی که به‌ندرت بازدید می‌شوند یا به کتابخانه‌های سنگین وابسته‌اند (مثل یک صفحه گزارش‌گیری پیچیده)، کاندیدای مناسب‌تری هستند.

یک نکته درباره Nested Routes

طبق ساختار Nested Routes که در فصل پانزدهم دیدیم، می‌توان تصمیم گرفت فقط برگ‌های درخت Routing (صفحات نهایی) lazy شوند، نه خود Layoutهای مشترک؛ چون Layout معمولاً در هر صفحه‌ای لازم است و lazy کردن آن، فقط یک فایل اضافه برای دانلود، بدون فایده واقعی در کاهش حجم اولیه، ایجاد می‌کند.

مثال قابل اجرا

در این مثال، Suspense فقط دور محتوای در حال تغییر قرار دارد، و دکمه دوم، شروع زودهنگام بارگذاری (شبیه Preload) را نشان می‌دهد.

Suspense در سطح Outlet و Preload دستی
import { useState, lazy, Suspense } from "react";

function loadSettingsPage() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ default: () => <p>صفحه تنظیمات بارگذاری شد.</p> });
    }, 1000);
  });
}

const SettingsPage = lazy(loadSettingsPage);
let preloadStarted = false;
let preloadedModule = null;

function App() {
  const [showSettings, setShowSettings] = useState(false);

  function handlePreload() {
    if (!preloadStarted) {
      preloadStarted = true;
      loadSettingsPage().then((m) => (preloadedModule = m));
    }
  }

  return (
    <div>
      <header>هدر ثابت (همیشه نمایش داده می‌شود)</header>
      <button onMouseEnter={handlePreload} onClick={() => setShowSettings(true)}>
        رفتن به تنظیمات (ماوس را نگه دارید تا preload شود)
      </button>

      <Suspense fallback={<p>در حال بارگذاری تنظیمات...</p>}>
        {showSettings && <SettingsPage />}
      </Suspense>

      <footer>فوتر ثابت</footer>
    </div>
  );
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

در ترکیب Routing با Code Splitting، جایگاه Suspense (دور کل برنامه یا فقط دور Outlet) تعیین می‌کند چه بخش‌هایی در طول بارگذاری یک صفحه، پایدار می‌مانند؛ و Preload در لحظه Hover، می‌تواند فاصله زمانی محسوس کلیک تا نمایش را کاهش دهد. تصمیم اینکه کدام صفحات lazy شوند، باید بر اساس فرکانس بازدید و حجم واقعی هر صفحه باشد. در درس بعدی، به ارتباط عمیق‌تر Code Splitting و Suspense می‌پردازیم.