跳到主要內容

FE Day 18 路由:巢狀、動態與載入狀態

FE Day 18 路由:巢狀、動態與載入狀態

執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第十八篇。昨天我們把 Next.js 15 App Router 起步了,知道了 Server/Client Component 的邊界、五個約定檔的位置。今天要進入路由設計的精華:「檔案系統即路由」怎麼表達巢狀、動態參數、載入狀態,以及「route group(路由群組)」怎麼在不改變網址的前提下切換 layout。我們會在 booking-fe 上加 /bookings、/bookings/[id]、/bookings/[id]/edit、/bookings/new 四條路由,並示範「側邊欄 layout + 內容區 page」的標準巢狀結構。讀完之後你應該能在 30 秒內決定任何一段新路由的檔案該放哪。

引言

寫後端的工程師對「URL 即資源」非常熟悉——GET /users/42/orders 表示「使用者 42 的所有訂單」。這個觀念跟 App Router 的檔案系統完全對應:app/users/[id]/orders/page.tsx 就是 /users/42/orders。資料夾是 URL 區段、檔案是端點、約定檔決定它扮演什麼角色。比起 Pages Router 的「全部路由寫進 pages/」、React Router 的「全部路由寫進一支設定檔」,App Router 的設計更接近後端的「目錄即資源」直覺。

今天重點放在三件事。第一,巢狀路由(nested routes)——資料夾一層一層對應到 URL 一層一層,layout.tsx 在每一層自動包下層,這是後端「共用 layout」最乾淨的寫法。第二,動態路由(dynamic routes)——[id]、[...slug]、[[...slug]] 三種括號的差別,以及 generateStaticParams 怎麼決定哪些動態值要預先建好。第三,載入與錯誤狀態——loading.tsx、error.tsx、not-found.tsx 放在「對應的目錄下」就只影響那一段路由,不會打擾其他頁面。我們也會順道提 route group(用 (name) 命名的資料夾)這個「不影響 URL 但影響 layout 群組」的特殊語法。

對照後端的經驗,App Router 的資料夾結構就像 FastAPI 的 router include——app/bookings/[id]/page.tsx 像 router.include_router(bookings_router, prefix="/bookings/{id}");巢狀 layout 像 dependency injection 的 scoping;loading.tsx 像 middleware 裡的「資料讀取中」狀態。學會這套語法之後,你會發現自己寫前端時的「找檔案」時間大幅縮短。

巢狀路由:資料夾層級即 URL 層級

巢狀路由是 App Router 最直覺的部分。我們在昨天的專案裡加 app/bookings/ 目錄,底下放 page.tsx 就立刻有 /bookings 這個路由:

src/app/
├── layout.tsx              # 根 layout
├── page.tsx                # /
├── bookings/               # /bookings
│   ├── page.tsx            # /bookings 的內容
│   └── layout.tsx          # bookings 段的 layout(選用)
├── bookings/
│   └── [id]/               # /bookings/:id
│       └── page.tsx
└── bookings/
    └── [id]/
        └── edit/           # /bookings/:id/edit
            └── page.tsx

這套結構對應的 URL 是 /bookings、/bookings/42、/bookings/42/edit。資料夾層級跟 URL 層級完全對應,看檔案結構就知道網站長什麼樣。對照到 React Router 的 createBrowserRouter,要寫好幾層巢狀的 children 陣列;對照 Pages Router 要靠檔名扁平化 pages/bookings/[id]/edit.tsx(雖然也能巢狀但 layout 難處理);App Router 用檔案系統就把這件事做完了。

「layout.tsx 自動包下層」是巢狀最關鍵的特性。在 app/bookings/layout.tsx 裡寫的 JSX 會自動包住 app/bookings/page.tsx、app/bookings/[id]/page.tsx 與 app/bookings/[id]/edit/page.tsx。根 app/layout.tsx 會包住整個 App(含 /bookings);app/bookings/layout.tsx 會再包一層。所以整個 render tree 是這樣:

// src/app/bookings/layout.tsx
// bookings 段專屬的 layout:側邊欄 + 內容區
import Link from "next/link";

export default function BookingsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[200px_1fr] gap-6 px-6 py-8">
      <aside className="space-y-2">
        <h2 className="text-lg font-semibold">預約管理</h2>
        <nav className="flex flex-col gap-1 text-sm">
          <Link href="/bookings" className="rounded px-2 py-1 hover:bg-slate-100">
            預約清單
          </Link>
          <Link href="/bookings/new" className="rounded px-2 py-1 hover:bg-slate-100">
            新增預約
          </Link>
        </nav>
      </aside>
      <section>{children}</section>
    </div>
  );
}

注意這個 layout 沒宣告 "use client",是純 Server Component——它只是把 children 渲染出來,不需要互動 state。側邊欄的 Link 是 Next.js 提供的 client 元件(內部用了 useRouter),但因為 layout 本身是 server,Link 是「被 server render、client hydrate」的模式,依然能正確運作且不會把整個 layout 變成 client bundle。

另一個重點:「layout.tsx 跨頁面時不會重新 mount」。當使用者在 /bookings 與 /bookings/42 之間切換,根 layout 與 bookings/layout.tsx 維持掛載狀態,只有內層 {children} 換掉。這個特性對「側邊欄的折疊狀態、影片播放、全域 modal」非常有用——這些 UI 不會因為換頁就被 unmount、狀態不會被重置。

動態路由:三種括號的差別

App Router 用三種括號語法處理動態參數,每種對應到不同的 URL 模式:

src/app/bookings/
├── [id]/                   # /bookings/42、/bookings/abc 都會命中
│   └── page.tsx            # params.id 拿到 "42"
├── [...slug]/              # /bookings/2026/03/29 都會命中(catch-all)
│   └── page.tsx            # params.slug = ["2026", "03", "29"]
└── [[...slug]]/            # /bookings 也命中,/bookings/2026/03/29 也命中
    └── page.tsx            # 沒給 slug 時 params.slug 是 undefined

[id] 是最常見的單一段動態參數,適用於「資源 ID」這類場景。[...slug] 是 catch-all,會把剩下的所有段都收進一個陣列,適用於「任意層級的路徑」(例如麵包屑、目錄樹)。[[...slug]] 是「optional catch-all」,連「不給任何參數」也命中,適用於「同一支頁面同時處理『首頁』與『子頁』」的場景,例如 docs 站的首頁與子章節。

在 page.tsx 裡透過 params prop 讀取動態參數:

// src/app/bookings/[id]/page.tsx
// 動態路由:Server Component 透過 params 拿到 URL 段
import { notFound } from "next/navigation";
import { getBooking } from "@/lib/api";
import { BookingDetail } from "@/features/booking/components/BookingDetail";

type Params = { id: string };

export default async function BookingDetailPage({
  params,
}: {
  params: Promise<Params>;
}) {
  const { id } = await params;
  const booking = await getBooking(id);
  if (!booking) notFound();
  return <BookingDetail booking={booking} />;
}

Next.js 15 把 params 改為 Promise,這是因為 Server Component 在 streaming 模式下需要保留「非同步解析 params」的能力。寫法就是 async function Page({ params }: { params: Promise<...> })、函式內 const { id } = await params。這個改動在升級 Next.js 14 → 15 時很容易漏掉,TypeScript 編譯器會抱怨 Property 'id' does not exist on type 'Promise<...>',記得在所有動態頁面加上 await。

對於「靜態參數需要預先知道」的場景(例如文章 ID 是固定的、想要 SSG),可以用 generateStaticParams:

// src/app/bookings/[id]/page.tsx
// 預先產生靜態頁面:build time 決定有哪些 id
import { listBookingIds } from "@/lib/api";
import { notFound } from "next/navigation";
import { getBooking } from "@/lib/api";
import { BookingDetail } from "@/features/booking/components/BookingDetail";

// 預先生成:把這些 id 在 build time 渲染成 HTML
export async function generateStaticParams(): Promise<{ id: string }[]> {
  const ids = await listBookingIds();
  return ids.map((id) => ({ id }));
}

type Params = { id: string };

export default async function BookingDetailPage({
  params,
}: {
  params: Promise<Params>;
}) {
  const { id } = await params;
  const booking = await getBooking(id);
  if (!booking) notFound();
  return <BookingDetail booking={booking} />;
}

generateStaticParams 回傳的陣列決定哪些動態值要在 build time 預渲染;其他沒列出的動態值會在 request time 才渲染。對「booking ID 會持續增加」的場景,可以搭配 dynamicParams = true(預設),未列出的 ID 仍可在 runtime 渲染。

載入狀態:loading.tsx 與 Suspense 的協作

loading.tsx 是約定檔,它會被框架自動包進 <Suspense>,在 page.tsx 的 promise 還沒 resolve 時顯示。我們在 app/bookings/loading.tsx 擺一個骨架畫面(skeleton):

// src/app/bookings/loading.tsx
// 約定檔:只影響 /bookings 段的載入狀態
export default function BookingsLoading() {
  return (
    <ul className="space-y-3" aria-busy="true" aria-live="polite">
      {Array.from({ length: 5 }).map((_, i) => (
        <li
          key={i}
          className="h-16 animate-pulse rounded bg-slate-200"
        />
      ))}
    </ul>
  );
}

放在 bookings/loading.tsx 而不是 app/loading.tsx 的差別:前者只影響 /bookings 與其子路由,後者影響整個 App。實務上越靠近葉節點的 loading 越精準,避免「切到任何頁面都看到全站 spinner」的粗糙體驗。

進階用法是直接在 JSX 裡用 <Suspense> 包裝「特定元件」,讓骨架更細緻。例如清單頁裡有一個「最近七天熱門時段」區塊,資料抓比較慢,可以這樣寫:

// src/app/bookings/page.tsx
// 內嵌 Suspense:細粒度的載入狀態
import { Suspense } from "react";
import { listBookings } from "@/lib/api";
import { BookingList } from "@/features/booking/components/BookingList";
import { HotSlotsPanel } from "@/features/booking/components/HotSlotsPanel";

export default async function BookingsPage() {
  // 清單本身立即讀取
  const bookings = await listBookings();
  return (
    <div className="space-y-6">
      <BookingList bookings={bookings} />
      {/* 熱門時段:可能慢,用 Suspense 包起來 */}
      <Suspense fallback={<div className="h-32 animate-pulse rounded bg-slate-100" />}>
        <HotSlotsPanel />
      </Suspense>
    </div>
  );
}

這段寫法的效果是「清單立刻出現、熱門時段的位置先顯示骨架、資料回來後自動換成實際內容」。Streaming SSR 會把 <HotSlotsPanel /> 的部分以 chunked HTML 送出,瀏覽器邊收邊畫,使用者體驗比「全部等齊再出現」好很多。

錯誤狀態:error.tsx 與邊界隔離

error.tsx 的邊界跟 loading.tsx 一樣是「越近越精準」。app/error.tsx 是全站最後防線;app/bookings/error.tsx 只接住 /bookings 段的錯誤,不會波及其他路由。對 booking 系統特別有用:清單頁打 API 失敗時,使用者仍然可以瀏覽首頁與其他頁面,不會整站掛掉。

// src/app/bookings/error.tsx
// 錯誤邊界:只接住 bookings 段的錯誤
"use client";

import { useEffect } from "react";

export default function BookingsError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="rounded border border-red-200 bg-red-50 p-4">
      <h2 className="font-semibold text-red-800">預約載入失敗</h2>
      <p className="mt-1 text-sm text-red-700">{error.message}</p>
      <button
        type="button"
        onClick={() => reset()}
        className="mt-3 rounded bg-red-700 px-3 py-1 text-white"
      >
        重試一次
      </button>
    </div>
  );
}

注意這個元件是 client(必須 client,因為 reset 需要瀏覽器端的 callback),並用 console.error 把錯誤送到監控系統(Day 30 會接 Sentry 之類的工具)。error.digest 是 Server Component 拋出錯誤時附的 hash,可以送到後端 log 對應真正的 stack trace。

「找不到資源」這個特定錯誤用 notFound() 函式處理。它會自動被 not-found.tsx 接住:

// src/app/bookings/[id]/not-found.tsx
// 約定檔:當這段路由的 notFound() 被呼叫時顯示
import Link from "next/link";

export default function BookingNotFound() {
  return (
    <div className="py-12 text-center">
      <h2 className="text-2xl font-bold">找不到這筆預約</h2>
      <p className="mt-2 text-slate-600">可能已被取消或連結過期。</p>
      <Link href="/bookings" className="mt-4 inline-block text-blue-700 underline">
        回預約清單
      </Link>
    </div>
  );
}

Route Group:用 (name) 切換 layout 不改 URL

Route Group 是 App Router 一個很容易被忽略、但很實用的語法:用「被括號包住的資料夾」(name) 不會出現在 URL,但會影響 layout 群組。常見的應用場景是「同一個 URL 前綴下,用不同 layout 切換不同身份的使用者」:

src/app/
├── (marketing)/            # 路由群組:對外頁面
│   ├── layout.tsx          # 含頁尾、導覽列的 layout
│   ├── page.tsx            # /(首頁)
│   └── about/page.tsx      # /about
└── (admin)/                # 路由群組:後台頁面
    ├── layout.tsx          # 含側邊欄後台 layout
    └── bookings/page.tsx   # /bookings(後台版)

兩支 page.tsx 的 URL 都是 /bookings 嗎?不對——這個例子裡 (admin)/bookings/page.tsx 的 URL 是 /bookings,但 (marketing)/ 底下沒有 bookings/,所以不會衝突。重點是「同一個 page.tsx 對應到哪個 layout.tsx 由它在哪個群組決定」。

實務上常見的用法還包括「未登入 vs 已登入」的 layout 切換:(public)/login/page.tsx 用簡潔的 layout;(app)/dashboard/page.tsx 用含使用者資訊的 layout。兩者 URL 不衝突,但渲染的 layout 不同。

導覽:Link、redirect 與 useRouter

頁面之間的跳轉有三種寫法。第一種是宣告式的 <Link href="...">,這是首選:框架會自動 prefetch 目標路由的 RSC payload,瀏覽器看到的是一般 anchor 標籤,鍵盤與螢幕閱讀器都能正確解讀。第二種是程式化的 router.push(...) 或 router.replace(...),用在「使用者操作之後跳轉」的情境(例如登入成功後導向 dashboard)。第三種是 Server Component 內的 redirect(...),用在「資料驗證失敗直接跳走」的情境(例如未登入就強制回首頁)。

// src/app/bookings/[id]/edit/page.tsx
// Server Component:用 redirect 做強制跳轉
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { getBooking } from "@/lib/api";
import { EditBookingForm } from "@/features/booking/components/EditBookingForm";

type Params = { id: string };

export default async function EditBookingPage({
  params,
}: {
  params: Promise<Params>;
}) {
  const session = await getSession();
  if (!session) redirect("/login");          // 未登入就跳轉

  const { id } = await params;
  const booking = await getBooking(id);
  if (!booking) redirect("/bookings");       // 找不到就回清單

  // 只有管理員能改
  if (session.role !== "admin") redirect(`/bookings/${id}`);

  return <EditBookingForm booking={booking} />;
}

redirect() 跟 notFound() 一樣都是 Server Component 內建的跳轉機制,差別在「找不到資源」對應 not-found.tsx,「權限不足或強制跳轉」對應 redirect()。呼叫 redirect() 會丟出一個特殊的 Next.js 錯誤讓框架攔截,前端不會看到 stack trace。

Client Component 裡要跳轉時,用 useRouter:

// src/features/auth/components/LoginForm.tsx
// Client Component:用 useRouter 做程式化跳轉
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";

export function LoginForm() {
  const router = useRouter();
  const [pending, setPending] = useState(false);

  async function handleSubmit(formData: FormData) {
    setPending(true);
    const res = await fetch("/api/auth/login", {
      method: "POST",
      body: formData,
    });
    if (res.ok) {
      router.push("/dashboard");    // 登入成功跳轉
    } else {
      setPending(false);
    }
  }

  return (
    <form action={handleSubmit} className="space-y-3">
      {/* 略 */}
    </form>
  );
}

useRouter 跟 React Router 的 useNavigate 類似,但底層用的是 App Router 的 client-side 路由:跳轉時不會整頁 reload,而是觸發新的 RSC fetch、把對應的 page 換掉。對 SPA 體驗特別重要。

另外有個常被忽略的小工具 usePathname(),能在 Client Component 讀到「目前 URL 路徑」,適合用來標記當前頁面、實作「當前位置高亮」的導覽列:

// src/features/nav/components/SidebarLink.tsx
// Client Component:用 usePathname 判斷當前位置
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

export function SidebarLink({ href, children }: { href: string; children: React.ReactNode }) {
  const pathname = usePathname();
  const active = pathname === href || pathname.startsWith(href + "/");
  return (
    <Link
      href={href}
      aria-current={active ? "page" : undefined}
      className={active ? "font-semibold text-blue-700" : "text-slate-700"}
    >
      {children}
    </Link>
  );
}

aria-current="page" 是 ARIA 標準屬性,告訴螢幕閱讀器「這是當前頁面」。鍵盤操作也會自動跳過這個連結(避免重複觸發)。搭配 Tailwind 的 className 切換,使用者能直接看出當前位置。

Query String 與 searchParams

除了 path 段的動態參數,URL 上的 query string(例如 /bookings?status=pending)也是常用的篩選介面。App Router 把 query string 收進 searchParams prop:

// src/app/bookings/page.tsx
// 讀 query string 篩選預約
import { listBookings } from "@/lib/api";
import { BookingList } from "@/features/booking/components/BookingList";
import { FilterBar } from "@/features/booking/components/FilterBar";

type SearchParams = { status?: string; q?: string };

export default async function BookingsPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  const { status, q } = await searchParams;
  const bookings = await listBookings({ status, q });

  return (
    <div className="space-y-4">
      <FilterBar status={status} query={q} />
      <BookingList bookings={bookings} />
    </div>
  );
}

跟 params 一樣,Next.js 15 的 searchParams 也是 Promise,要在 async 函式內 await 取得值。常見的篩選邏輯直接寫在 page.tsx:使用者點篩選條件、表單 submit 到同一個 URL、框架重新跑 page.tsx、套新的 query string 重新撈資料。不需要寫 useState、不需要 React Query,純 Server Component 就能處理大部分的篩選 UI。

「使用者點篩選條件、表單 submit 到同一個 URL」這條 UI 模式稱為「progressive enhancement」(漸進增強):即使 JavaScript 沒跑(搜尋引擎爬蟲、純文字瀏覽器),使用者依然能用 form submit 改變 URL 與資料;JavaScript 跑起來時,再用 useRouter().replace() 讓體驗更滑順。實作上常見的寫法:

// src/features/booking/components/FilterBar.tsx
// 篩選列:純 form 也能跑,加 client 體驗更佳
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";

export function FilterBar({ status, query }: { status?: string; query?: string }) {
  const router = useRouter();
  const params = useSearchParams();
  const [pending, startTransition] = useTransition();

  function apply(next: { status?: string; q?: string }) {
    const sp = new URLSearchParams(params);
    if (next.status) sp.set("status", next.status);
    else sp.delete("status");
    if (next.q) sp.set("q", next.q);
    else sp.delete("q");

    startTransition(() => {
      router.replace(`/bookings?${sp.toString()}`);
    });
  }

  return (
    <div className="flex gap-2" aria-busy={pending}>
      <button type="button" onClick={() => apply({ status: "pending" })}>
        待處理
      </button>
      <button type="button" onClick={() => apply({ status: "done" })}>
        已完成
      </button>
    </div>
  );
}

useTransition 是 React 19 的新 API,告訴 React「這次更新可以中斷、不影響 UI 響應」。搭配 startTransition 使用時,篩選按鈕的點選不會等資料回來才更新 UI,而是立即更新並標 aria-busy。對 booking 預約系統這種「點按鈕就刷資料」的互動特別有用。

觀察 Layout 行為:DevTools 的實際驗證

想驗證「layout.tsx 跨頁面不重新 mount」這件事,最直接的方式是打開 React DevTools 並觀察元件層級。打開 React DevTools 的 Components 面板,從根 html 一路展開,會看到 RootLayout 與 BookingsLayout 都有獨立的 fiber。在 /bookings 與 /bookings/42 之間切換時,這兩個 fiber 的狀態(如 useState)不會被重置,只有內層的 {children} 換掉。如果在 BookingsLayout 內放一個 useState(0) 的計數器,跨頁切換時計數不會歸零——這就是「layout 不重新 mount」的視覺化證據。

另一個驗證方式是看 Network 面板。在 /bookings 與 /bookings/42 之間切換時,瀏覽器會送出新的 RSC request(payload 是 React tree 的差量描述),不會重新下載整個 HTML 與 JavaScript bundle。Chrome DevTools 的 Network 面板能直接看到「RSC」請求,檢查它的 response body 就能看到 React 19 對應的差量描述。

對照後端的經驗,這套「layout 不重新 mount + 內層換掉」的模型最接近 SPA 的 client-side routing,但因為 RSC payload 是純資料(沒有 JavaScript),整體成本遠比 SPA 低。對 booking 預約系統這種「後台介面偏多、頁面間共享 layout」的應用,這個特性可以讓側邊欄的折疊狀態、表單的未儲存提示、影片播放器等持續運作的 UI 不被換頁打斷。

常見錯誤與踩雷

第一個雷是「[id]/page.tsx 沒升級到 Next.js 15 的 params Promise」。升級到 15 之後 params 是 Promise<{...}>,舊寫法 function Page({ params }: { params: { id: string } }) 會在生產 build 時跳錯(dev 有時不會,因為有 fallback)。修法是把型別改成 params: Promise<{ id: string }>、函式內加 await params。CI 一定要跑 pnpm build 才能抓到這個錯誤。

第二個雷是「在 loading.tsx 用 client 元件(例如 useEffect 寫動畫)」。loading.tsx 是骨架畫面,本質上是 Server Component;硬要加互動動畫會出現代價。如果你需要 skeleton 帶 pulse 動畫,用 Tailwind 的 animate-pulse class 即可,這是純 CSS、不需要 client。

第三個雷是「error.tsx 忘了加 "use client"」。這個錯誤訊息很明確(「error components must be Client Components」),但新手常常看到還是想「為什麼一定要 client」。原因就是 reset 函式必須在瀏覽器端執行。修法照做即可。

第四個雷是「generateStaticParams 在 build time 跑得太慢」。如果動態 ID 有幾萬個,generateStaticParams 在 build time 會呼叫 API 把所有 ID 撈回來,可能讓 CI build 從 1 分鐘變 10 分鐘。這時可以考慮:縮限範圍(只預先生成前 100 筆熱門 ID)、改用 ISR(Incremental Static Regeneration)、或完全不預先生成(讓所有 ID 走 request time)。

第五個雷是「route group 的 layout 互相打架」。如果在 (marketing)/layout.tsx 與 (admin)/layout.tsx 都各自包了 <html> 標籤,build 會跳「Multiple root layouts detected」錯誤。根 app/layout.tsx 是唯一可以有 <html> 與 <body> 的檔案,route group 的 layout 只能包 <div> 之類的區段。

效能與實務提醒

巢狀 layout 的成本要記得算進去。每多一層 layout.tsx,首屏就要多 render 一層 React tree;如果 layout 內有 fetch 動作(例如讀使用者 session),這層的等待時間會被加進 first paint。實務上「RootLayout 保持精簡、不放 fetch」是最常見的最佳化;把需要 fetch 的部分移到 app/<section>/layout.tsx 或用 React Suspense 切段。

Streaming SSR 的效益在巢狀結構特別明顯。當 page.tsx 還在 await 資料時,layout 與 loading.tsx 已經送到瀏覽器並開始繪製,使用者看到骨架後資料跟上。沒有 streaming 的 SSR 時代,整個 page 要等齊才能送 HTML,TTFB(Time To First Byte)與 FCP(First Contentful Paint)都會比較差。實務上把 loading.tsx 放在「最有可能慢的目錄」是最佳化重點。

最後是「Link prefetch」。<Link> 在 viewport 內會自動 prefetch 目標路由的 RSC payload,切換頁面幾乎是即時。但 prefetch 會消耗流量,行動裝置或低速網路要節制。Next.js 15 提供 <Link prefetch={false}> 關閉特定連結的 prefetch,例如「登出」「外部連結」「使用者極少點的連結」。

小結

今天把 App Router 的檔案系統即路由拆成五塊:巢狀 layout(資料夾層級 = URL 層級 + layout.tsx 自動包下層)、動態路由([id]、[...slug]、[[...slug]] 三種括號對應三種模式)、載入狀態(loading.tsx 在對應目錄只影響該段、可搭配 <Suspense> 內嵌細粒度骨架)、錯誤狀態(error.tsx 同樣依目錄邊界隔離、notFound() 觸發 not-found.tsx)、route group(用 (name) 切換 layout 不改 URL)。這五塊合在一起就是「多頁面 + 共享 layout + 動態參數 + 優雅失敗」的標準 Next.js 模式。

在 booking 預約系統的脈絡下,這套結構讓我們能用「資料夾即資源」的直覺組織程式:/bookings 是一個目錄、/bookings/42 是子目錄、每層都有自己的 layout 與錯誤邊界。明天 Day 19 會把 Server Component 的資料取得設計展開:怎麼在元件裡 await fetch、四種快取策略怎麼選、<Suspense> 怎麼跟 loading.tsx 協作把 TTFB 縮到最短。

結語

明天,我們會正式進入 Day 19「Server Components 與資料取得」。我們會在 /bookings 與 /bookings/[id] 兩條路由上把 fetch 包進 src/lib/api.ts,並示範 no-store、force-cache、revalidate 三種快取策略的差別。你會看到 Server Component 如何直接 await 後端、為什麼 bundle 不會包含 API 網址、以及 streaming 模式下資料是怎麼「邊來邊送」的。

延伸資源

  • Next.js 官方 Routing 章節:https://nextjs.org/docs/app/building-your-application/routing
  • Next.js 官方 Dynamic Routes 章節:https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes
  • Next.js 官方 Loading UI 與 Streaming:https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
  • Next.js 官方 Route Groups:https://nextjs.org/docs/app/building-your-application/routing/route-groups
  • Next.js 15 generateStaticParams 行為:https://nextjs.org/docs/app/api-reference/functions/generate-static-params

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...