Day22\examples\day22-router-lab
在還沒有 React Router 之前,先花點時間搞懂它到底解決了什麼問題,之後用起來才不會只是「照抄語法」。
一般傳統網站(Multi-Page Application, MPA)長這樣:每個網址對應伺服器上的一份 HTML 檔案。使用者點擊 <a href="/about">關於我們</a> 之後:
畫面會「白一下」再出現新內容,就是這個原因。這件事本身沒有不好——甚至對純內容型網站(部落格、文件站)來說,這樣做更單純。但如果是一個互動性很高的應用程式(例如 Day01~Day21 我們寫的這些 React 小專案),每次換頁都整頁重新載入,會把 React 在瀏覽器端維護的所有狀態全部砍掉重練,體驗很差。
我們從 Day01 開始寫的 React 專案,其實一直都是 SPA(Single Page Application):整個網站實際上只有一份 index.html,之後所有畫面內容都是 JavaScript 在瀏覽器端動態渲染出來的。
如果沒有專門的路由套件,最直覺的「切換畫面」寫法可能長這樣:
function App() {
const [page, setPage] = useState("home");
return (
<div>
<button onClick={() => setPage("home")}>首頁</button>
<button onClick={() => setPage("about")}>關於</button>
{page === "home" && <HomePage />}
{page === "about" && <AboutPage />}
</div>
);
}
這樣寫可以動,畫面確實會切換,而且完全不會整頁重新載入。但實際做成產品會遇到三個現實問題:
page === 'home' 的初始畫面。React Router 存在的核心目的,就是解決「SPA 換頁時,畫面跟網址對不起來」這件事。它讓我們:
window.history.pushState())偷偷把網址列換掉,同時觸發 React Router 內部重新比對、重新渲染對應元件——全程沒有真正對伺服器發出新的整頁請求
popstate 事件),正確地把畫面切回對應的歷史紀錄也就是說,React Router 幫我們把「手動用 useState 決定要顯示哪個畫面」這件事,升級成「網址本身就是狀態的來源(URL as source of truth)」。下表整理三者的差異:
| 面向 | 傳統多頁(MPA) | 手刻 useState 切換畫面 |
SPA + React Router |
|---|---|---|---|
| 換頁時做什麼 | 對伺服器發出全新請求 | JS 直接抽換畫面 | JS 抽換畫面 + 呼叫 History API |
| 網址列 | 每頁都不同、正確 | 永遠不變 | 跟著畫面正確改變 |
| 重新整理頁面 | 停在同一頁 | 回到初始畫面 | 停在同一頁 |
| 上一頁/下一頁 | 瀏覽器原生支援 | 失效 | 正確支援 |
| 換頁體感 | 畫面會白一下 | 不會整頁重載 | 不會整頁重載 |
💡 小提醒:React Router 底層雖然用了 History API,但我們平常寫程式幾乎不會直接呼叫
pushState,而是使用它提供的<Link>、<NavLink>、useNavigate()等元件與 Hook——這些工具的內部都已經幫我們處理好呼叫時機了。
從 React Router v7 開始,套件已經整合成單一個 react-router,不再需要像早期那樣額外安裝 react-router-dom。
npm install react-router@8.3.0
安裝完成後,package.json 會多出類似這行:
"dependencies": {
"react-router": "^8.3.0"
}
有兩個匯入路徑需要分清楚:
| 匯入來源 | 用途 | 範例 |
|---|---|---|
react-router |
核心 API:建立路由、<Link>/<NavLink>、各種 Hook |
createBrowserRouter、useNavigate、useLocation |
react-router/dom |
專門給瀏覽器環境使用、經過最佳化的 RouterProvider |
RouterProvider |
官方文件(
https://reactrouter.com/start/data/installation)明確建議:在瀏覽器專案中,RouterProvider要從react-router/dom匯入,而不是從react-router匯入——兩者雖然同名,但react-router/dom版本針對瀏覽器情境做了額外優化(例如可以判斷連結是否可以預先讀取資料)。今天的範例會確實遵守這個慣例。
React Router 需要 React 19.2 以上版本作為 peer dependency,我們從 Day01 開始就一直是 React 19,所以這裡不需要額外升級。
這是最貼近「一般 React 元件寫法」的模式,整個路由表用 JSX 元件樹表達:
import { BrowserRouter, Routes, Route } from "react-router";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
<BrowserRouter>:包住整個應用程式,負責跟瀏覽器的 History API 溝通。<Routes>:在目前這個位置,比對網址、決定要渲染哪一個 <Route>。<Route path="..." element={...} />:宣告「這個網址對應這個元件」。Data Mode 把同一份路由表,改成用純 JavaScript 物件陣列描述,再交給 createBrowserRouter 建立、用 <RouterProvider> 渲染:
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
const router = createBrowserRouter([
{ path: "/", element: <HomePage /> },
{ path: "/about", element: <AboutPage /> },
{ path: "*", element: <NotFoundPage /> },
]);
function App() {
return <RouterProvider router={router} />;
}
單看「頁面切換」這個功能,兩種寫法效果幾乎一模一樣,<Link>、<NavLink>、useNavigate() 等 API 在兩種模式下也是共用、寫法完全相同。真正的差異在於:Data Mode 因為路由是「一份資料」而不是「一段 JSX」,所以可以在每個路由物件上額外掛上 loader(進頁面前先載入資料)、action(處理表單送出)等「資料 API」,這些能力在 Declarative Mode 下沒辦法使用。
| Declarative Mode | Data Mode | |
|---|---|---|
| 路由怎麼寫 | JSX 元件樹(<Routes>/<Route>) |
JavaScript 物件陣列(createBrowserRouter([...])) |
| 進入方式 | <BrowserRouter> 包住 App |
<RouterProvider router={router} /> 渲染 App |
| Link/NavLink/useNavigate | ✅ 支援 | ✅ 支援(寫法相同) |
loader/action(資料載入、表單處理) |
❌ 不支援 | ✅ 支援( |
| 適合情境 | 只需要單純換頁;或從舊版 React Router 升級上來 | 除了換頁,之後也會需要資料載入、表單送出等進階功能 |
這個系列接下來後續會學到 Nested Routes/<Outlet>/URL 參數、loader/action 資料載入與路由守衛——而 loader/action 這些 API 只存在於 Data Mode。與其之後再把整個路由架構打掉重練,不如今天就用 createBrowserRouter 把架構搭好,之後只需要在既有的路由物件上「多加幾個欄位」即可,不需要更動渲染 App 的方式。
也因為這樣,今天範例程式碼裡看到的 router.jsx,會是一個獨立檔案,export 出一個用 createBrowserRouter 建立好的 router 常數。
💡 小提醒:Data Router(
createBrowserRouter建立出來的物件)應該在模組最外層建立「一次」,不要放進元件內部用useState/useMemo動態建立,也不要每次渲染都重新呼叫createBrowserRouter——路由表在應用程式執行期間通常是固定不變的設定資料,而不是會隨畫面重新計算的狀態。
createBrowserRouter 建立路由設定有了基本概念,來看路由設定陣列裡每個路由物件的組成:
const router = createBrowserRouter([
{
path: "/window-size", // 要比對的網址路徑
element: <WindowSizePage />, // 比對成功時要渲染的元素
},
// ...更多路由
]);
path:字串,描述這個路由要比對的網址路徑。例如 '/window-size' 只會比對 /window-size 這個網址。element:比對成功後要渲染的 React 元素(用法跟平常寫 JSX 一樣,可以正常傳入 props)。React Router 也支援用
Component: WindowSizePage(傳元件本身,而不是先呼叫它產生元素)這種寫法,效果相同、由 React Router 幫你呼叫。今天的範例統一使用element,這樣不管在 Declarative Mode 的<Route element={...} />還是 Data Mode 的{ element: ... },都是同一個「用element指定畫面」的概念,比較不容易搞混。
一份最小可執行的路由設定,會搭配 main.jsx/App.jsx 這樣使用:
// App.jsx
import { RouterProvider } from "react-router/dom";
import { router } from "./router.jsx";
function App() {
return <RouterProvider router={router} />;
}
export default App;
RouterProvider 拿到 router 之後,會負責:讀取目前瀏覽器網址 => 拿去跟路由陣列逐一比對 => 找到符合的路由 => 渲染它的 element。之後只要透過 <Link>/<NavLink> 或 useNavigate() 換頁,RouterProvider 都會自動重新跑一次這個流程。
<Link> 與 <NavLink><a href="...">{
/* ❌ 不建議:會整頁重新載入,回到「傳統 MPA」的行為 */
}
<a href="/about">關於</a>;
原生 <a> 標籤點下去,瀏覽器就是會真的發出 HTTP 請求、整頁重新載入——這正是我們在第一節想避免的行為。React Router 提供的 <Link> 元件,外觀跟使用方式幾乎跟 <a>一樣,但點擊時會攔截瀏覽器預設的換頁行為,改成呼叫 History API,讓畫面在不整頁重新載入的前提下換頁:
import { Link } from "react-router";
<Link to="/about">關於</Link>;
💡
<Link>渲染出來的還是一個<a>標籤(只是加上了自己的onClick攔截邏輯),所以「用滑鼠中鍵開新分頁」「按住 Ctrl/Cmd 點擊開新分頁」這些瀏覽器原生行為都完整保留,這也是為什麼平常換頁應該優先使用<Link>,而不是用onClick搭配useNavigate()手動導頁。
<NavLink>:多了「目前在哪一頁」的狀態<NavLink> 的用法跟 <Link> 幾乎一模一樣,差別是它會自動判斷「目前網址是不是符合這個連結」,這在做導覽列時非常實用:
import { NavLink } from "react-router";
<NavLink
to="/window-size"
className={({ isActive }) =>
isActive ? "nav-link nav-link--active" : "nav-link"
}
>
useWindowSize
</NavLink>;
className 可以傳一個函式,React Router 會呼叫它並傳入 { isActive }(目前網址是否符合這個連結)等狀態,讓我們決定要套用哪個 CSS 類別。今天範例的 NavBar.jsx 就是用這個方式,讓目前所在的頁面在導覽列上高亮顯示。
end 屬性:Home 連結的陷阱<NavLink> 判斷「是否符合」的預設規則是前綴比對:只要目前網址是用這個連結的 to 開頭,就算符合。這在大部分連結上沒問題,但 Home 頁面的路徑通常是 /,而幾乎所有網址都是以 / 開頭,結果就是:
{
/* ❌ 沒加 end:不管切到哪一頁,這個連結都會被判斷成 isActive */
}
<NavLink to="/">首頁</NavLink>;
解法是幫這種「根路徑」連結加上 end 屬性,要求完全比對才算符合:
{
/* ✅ 加上 end:只有網址「剛好」是 "/" 時才會高亮 */
}
<NavLink to="/" end>
首頁
</NavLink>;
今天範例的 NavBar.jsx 就特別只在首頁連結加上 end,其他子頁面連結則不需要(因為它們的路徑本來就不會是別的路徑的前綴)。
useNavigate除了讓使用者「點擊連結」換頁,有些情境是「程式自己決定要換頁」,例如表單送出成功後自動跳轉、或倒數計時後自動導回首頁。這種情境會用到 useNavigate() 這個 Hook:
const navigate = useNavigate();
function handleSubmitSuccess() {
navigate("/success");
}
useNavigate 屬於「主動命令式換頁」,Day23 會有更完整的實際使用場景;一般使用者點擊觸發的換頁,仍然建議優先用 <Link>/<NavLink>。
path: '*' 接住所有沒比對到的網址React Router 的路由比對是由上而下、比對到第一個符合的就停止,所以只要在路由陣列的最後面放一個 path: '*'(萬用路由,會比對任何路徑),就能接住所有「上面路由都比對不到」的網址:
const router = createBrowserRouter([
{
path: "/",
element: (
<Layout>
<HomePage />
</Layout>
),
},
{
path: "/window-size",
element: (
<Layout>
<WindowSizePage />
</Layout>
),
},
// ...其他正常路由
// 一定要放最後:前面都比對不到,才會落到這裡
{
path: "*",
element: (
<Layout>
<NotFoundPage />
</Layout>
),
},
]);
如果把 path: '*' 放在陣列最前面,因為它會比對任何路徑,後面所有路由都會永遠比對不到,變成整個網站都只顯示 404 頁面——這是初學者很容易踩到的順序陷阱,實作時要特別留意。
這裡有個值得特別澄清的觀念:我們做出來的 404 頁面,是瀏覽器裡的 JavaScript 自己判斷網址、決定顯示"找不到頁面"文字,並不是伺服器真的回應了 HTTP 404 狀態碼。實際上不管網址是什麼,SPA 情境下伺服器(或開發時的 Vite dev server)通常都是回傳同一份 index.html,狀態碼是 200,然後才由 React Router 在瀏覽器端接手,判斷這個網址沒有對應的路由、渲染出 NotFoundPage。
這帶來一個實務上要注意的重點:部署到正式環境的靜態代管平台時,伺服器必須設定「找不到對應檔案的網址,一律回傳 index.html」(常見作法是 Netlify 的 _redirects、Nginx 的 try_files),使用者重新整理一個像 /window-size 這樣的子路徑時,才不會直接被伺服器擋下、顯示伺服器層級真正的 404 錯誤頁。Vite 的開發伺服器預設就內建了這種行為,所以開發階段不會注意到這件事,但正式部署時要記得處理。
之後過幾天會介紹
loader拋出Response觸發的errorElement——那是「伺服器(或資料層)明確判斷這是一筆錯誤」的情境,跟今天單純「網址沒有對應路由」的前端 404 概念不同,先知道兩者的差異即可。
Day21 的範例把 useWindowSize、useLocalStorage、useDebounce、useFetch 四個自訂 Hook 的 Demo,全部疊在同一個頁面上、由上往下捲動查看。今天的練習,就是把它改成「一個 Hook 一個網址」的多頁面架構:

四個 Hook(useLocalStorage、useWindowSize、useFetch、useDebounce)與四個對應的展示元件(WindowSizeDemo、LocalStorageDemo、DebounceDemo、CombinedSearchDemo)程式碼完全沒有變動(只把 localStorage 的 key 從 day21-* 改成 day22-*,避免兩天的範例互相污染彼此的瀏覽器儲存)——今天的重點單純是「路由外層的組裝方式」,這也呼應了 React 元件化的精神:畫面邏輯(Hook + Demo 元件)跟「怎麼把畫面組合、切換」是可以分開處理的兩件事。
day22-router-lab/
├── server/ # Day21 沿用的 Express 商品搜尋 API(port 4022)
│ ├── index.js
│ └── package.json
└── app/... (下方省略,實際檔案在專案根目錄)
├── src/
│ ├── hooks/ # 與 Day21 完全相同的四個自訂 Hook
│ │ ├── useLocalStorage.js
│ │ ├── useWindowSize.js
│ │ ├── useFetch.js
│ │ ├── useDebounce.js
│ │ └── index.js # 統一匯出的 barrel file
│ ├── components/
│ │ ├── NavBar.jsx # 導覽列(NavLink + end)
│ │ ├── Layout.jsx # 共用外框(NavBar + 內容)
│ │ ├── WindowSizeDemo.jsx
│ │ ├── LocalStorageDemo.jsx
│ │ ├── DebounceDemo.jsx
│ │ └── CombinedSearchDemo.jsx
│ ├── pages/ # 新增:一個路由對應一個頁面元件
│ │ ├── HomePage.jsx
│ │ ├── WindowSizePage.jsx
│ │ ├── LocalStoragePage.jsx
│ │ ├── DebouncePage.jsx
│ │ ├── SearchPage.jsx
│ │ └── NotFoundPage.jsx
│ ├── router.jsx # 新增:createBrowserRouter 路由設定
│ ├── App.jsx # 改寫:只負責渲染 RouterProvider
│ ├── App.css
│ ├── main.jsx
│ └── index.css
└── vite.config.js # /api 代理到 http://localhost:4022
實際檔案路徑是
examples/day22-router-lab/(Express 後端與 Vite 前端都放在同一個資料夾內,server/是後端、其餘是前端,跟 Day21 的擺放方式一致)。
| 網址路徑 | 頁面元件 | 內容 |
|---|---|---|
/ |
HomePage |
首頁:簡短介紹 + 連到四個 Hook 頁面的卡片連結 |
/window-size |
WindowSizePage |
展示 useWindowSize:即時顯示目前視窗寬高 |
/local-storage |
LocalStoragePage |
展示 useLocalStorage:計數器數值會存進瀏覽器、重新整理不會歸零 |
/debounce |
DebouncePage |
展示 useDebounce:輸入框防抖動效果 |
/search |
SearchPage |
展示 useDebounce + useFetch:向 Express API 查詢商品 |
*(其他任何路徑) |
NotFoundPage |
404 找不到頁面,附一個回首頁的 <Link> |
router.jsx:路由設定總表import { createBrowserRouter } from "react-router";
import Layout from "./components/Layout.jsx";
import HomePage from "./pages/HomePage.jsx";
import WindowSizePage from "./pages/WindowSizePage.jsx";
import LocalStoragePage from "./pages/LocalStoragePage.jsx";
import DebouncePage from "./pages/DebouncePage.jsx";
import SearchPage from "./pages/SearchPage.jsx";
import NotFoundPage from "./pages/NotFoundPage.jsx";
// 每一筆路由的 element 都用 <Layout> 包一層,讓導覽列(NavBar)在
// 每個頁面之間切換時都能持續顯示,不會因為換頁而消失或重新製作。
export const router = createBrowserRouter([
{
path: "/",
element: (
<Layout>
<HomePage />
</Layout>
),
},
{
path: "/window-size",
element: (
<Layout>
<WindowSizePage />
</Layout>
),
},
{
path: "/local-storage",
element: (
<Layout>
<LocalStoragePage />
</Layout>
),
},
{
path: "/debounce",
element: (
<Layout>
<DebouncePage />
</Layout>
),
},
{
path: "/search",
element: (
<Layout>
<SearchPage />
</Layout>
),
},
// 萬用路由(catch-all):必須放在陣列最後面,否則它會搶先比對成功,
// 讓後面所有路由都變得不會執行。
{
path: "*",
element: (
<Layout>
<NotFoundPage />
</Layout>
),
},
]);
router這裡是用具名匯出(named export),所以其他檔案要用import { router } from './router.jsx'引入,而不是預設匯出的import router from './router.jsx',等一下App.jsx的寫法要留意這個細節。
Layout.jsx:用 children 組合共用導覽列(先不用 <Outlet>)import NavBar from "./NavBar.jsx";
// Layout:所有頁面共用的外層結構(導覽列 + 內容區)。
//
// 這裡刻意用最單純的「一般 React children props 組合」來達成每個頁面
// 都看得到同一個 NavBar,並沒有使用 React Router 的巢狀路由(nested
// routes)與 <Outlet />——那是 Day23「巢狀路由與 Layout 元件設計」要
// 學的進階寫法。Day22 先只專心搞懂兩件事:「網址對應頁面元件」,以及
// 「用 <Link>/<NavLink> 在頁面之間導覽」。
function Layout({ children }) {
return (
<div className="app-shell">
<NavBar />
<main className="page-content">{children}</main>
</div>
);
}
export default Layout;
Layout本身不負責內容區的內距(padding)留白,而是交給每個頁面元件自己在最外層包一個<div className="page-inner">(下一小節可以看到)。這樣拆分後,Layout只需要專心處理「導覽列 + 內容容器」這一件事,版面留白則跟著每個頁面各自的排版彈性調整。
NavBar.jsx:NavLink + end 實戰import { NavLink } from "react-router";
// 導覽列上每一個連結對應的路由 path 與顯示文字,之後如果要增加頁面,
// 只需要在這裡加一筆設定,並在 router.jsx 註冊對應的路由即可。
const NAV_ITEMS = [
{ to: "/", label: "首頁", end: true },
{ to: "/window-size", label: "useWindowSize" },
{ to: "/local-storage", label: "useLocalStorage" },
{ to: "/debounce", label: "useDebounce" },
{ to: "/search", label: "商品搜尋" },
];
// NavBar:用 <NavLink> 而不是 <Link>,因為導覽列需要知道「目前在哪一頁」
// 並且加上高亮樣式(active 狀態),這是 NavLink 比 Link 多出來的能力。
function NavBar() {
return (
<nav className="nav-bar">
<span className="nav-brand">Day22 · Hook Router</span>
<ul className="nav-list">
{NAV_ITEMS.map((item) => (
<li key={item.to}>
{/* end:只有網址「完全等於」to 才算 active。
首頁 "/" 一定要加 end,否則因為每個網址都是以 "/" 開頭,
首頁的連結會永遠被判定成 active,其他頁面反而無法正確高亮。 */}
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`nav-link${isActive ? " nav-link--active" : ""}`
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
);
}
export default NavBar;
五個實際頁面(HomePage 除外)都共用同一種結構:最外層包一個 <div className="page-inner">(前一小節提到、由每個頁面自己負責的內距容器),裡面放一段 page-header(小標籤 eyebrow + 標題 + 說明文字),再放進 Day21 就寫好的 Demo 元件,例如:
// pages/WindowSizePage.jsx
import WindowSizeDemo from "../components/WindowSizeDemo.jsx";
// WindowSizePage:路由 "/window-size" 對應的頁面,內容就是 Day21 的
// WindowSizeDemo——今天的重點是「這個 Hook 有了自己獨立的網址」,
// Hook 本身的實作完全沒有改變。
function WindowSizePage() {
return (
<div className="page-inner">
<header className="page-header">
<p className="eyebrow">Hook 路由頁面</p>
<h1>useWindowSize</h1>
<p className="subtitle">
延續 Day13、Day21 的實作:兩個互不認識的元件各自呼叫一次{" "}
<code>useWindowSize()</code>,卻共用同一套「訂閱 resize
事件、卸載時取消訂閱」的邏輯。
</p>
</header>
<div className="card-grid">
<WindowSizeDemo />
</div>
</div>
);
}
export default WindowSizePage;
其餘三個頁面(LocalStoragePage、DebouncePage、SearchPage)都是相同的寫法,只是換掉標題、說明文字,以及放進去的 Demo 元件(分別是 LocalStorageDemo、DebounceDemo、CombinedSearchDemo)。
首頁 HomePage.jsx 則是把四個 Hook 頁面整理成一組資料,直接 map 成可以點擊的 <Link> 卡片,讓使用者不用打開導覽列,也能一眼看到今天有哪些 Demo:
// pages/HomePage.jsx
import { Link } from "react-router";
const HOOK_LINKS = [
{
to: "/window-size",
title: "useWindowSize",
desc: "即時偵測瀏覽器視窗尺寸……",
},
{
to: "/local-storage",
title: "useLocalStorage",
desc: "把 state 自動同步進 localStorage……",
},
{
to: "/debounce",
title: "useDebounce",
desc: "打字停下來一段時間後值才會「安定」下來……",
},
{
to: "/search",
title: "商品搜尋(Debounce + Fetch)",
desc: "把 useDebounce 與 useFetch 組合起來……",
},
];
function HomePage() {
return (
<div className="page-inner">
{/* ...page-header 省略... */}
<div className="card-grid">
{HOOK_LINKS.map((item) => (
<Link key={item.to} to={item.to} className="card link-card">
<h2>{item.title}</h2>
<p className="card-desc">{item.desc}</p>
</Link>
))}
</div>
</div>
);
}
404 頁面則附上一個導回首頁的連結:
// pages/NotFoundPage.jsx
import { Link } from "react-router";
// NotFoundPage:router.jsx 裡 path: '*' 的萬用路由對應的頁面。
// 只要目前網址沒有被其他任何一筆路由設定比對到,就會顯示這個頁面,
// 這是最基本、也最常見的「404 頁面」實作方式。
function NotFoundPage() {
return (
<div className="page-inner">
<div className="not-found">
<p className="not-found__code">404</p>
<h1>找不到這個頁面</h1>
<p className="subtitle">
網址可能打錯了,或這個頁面已經搬家。可以先回首頁,重新選擇想瀏覽的
Hook 範例。
</p>
<Link to="/" className="secondary-btn">
回首頁
</Link>
</div>
</div>
);
}
export default NotFoundPage;
App.jsx 與 main.jsx:RouterProvider 進場// App.jsx
import { RouterProvider } from "react-router/dom";
import "./App.css";
import { router } from "./router.jsx";
// RouterProvider 刻意從 'react-router/dom' 匯入(而不是 'react-router'):
// 這是官方文件建議在瀏覽器(ReactDOM)環境下使用的版本。
function App() {
return <RouterProvider router={router} />;
}
export default App;
// main.jsx(跟之前每天的結構一致,維持 StrictMode)
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);
可以看到 App.jsx 變得非常單純——所有「該顯示哪個頁面」的邏輯,都已經交給 router.jsx 這份設定表跟 RouterProvider 處理了。注意 router.jsx 是用具名匯出,所以這裡要用 import { router } from './router.jsx',而不是預設匯出的寫法。
/search 頁面一樣需要向後端要資料,server/index.js 是把 Day21 的 Express 服務搬過來、把埠號改成 4022(維持「Day 幾號、埠號末兩碼就是幾號」的慣例:Day20 是 4020、Day21 是 4021):
// server/index.js
import express from "express";
import cors from "cors";
const app = express();
const PORT = process.env.PORT || 4022;
app.use(cors());
// products:20 筆商品的固定假資料(略)
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// GET /api/products?q=鍵盤
app.get("/api/products", async (req, res) => {
const keyword = (req.query.q || "").trim();
// 記錄每一次「真正打到伺服器」的請求,方便對照前端畫面上的次數統計,
// 驗證 useDebounce 真的減少了呼叫次數。
console.log(
`[day22-router-lab] GET /api/products?q=${keyword || "(空字串,回傳全部)"}`,
);
await delay(400); // 固定模擬 400ms 網路延遲
const matched = keyword
? products.filter(
(product) =>
product.name.toLowerCase().includes(keyword.toLowerCase()) ||
product.category.toLowerCase().includes(keyword.toLowerCase()),
)
: products;
res.json({
query: keyword,
count: matched.length,
products: matched,
fetchedAt: new Date().toISOString(),
});
});
app.listen(PORT, () => {
console.log(
`[day22-router-lab] Express server ready at http://localhost:${PORT}`,
);
});
留意回傳格式是
{ query, count, products, fetchedAt }這個物件,而不是單純的商品陣列——前端CombinedSearchDemo.jsx是透過data.products、data.count讀取資料,兩邊的資料形狀需要對得起來。
前端 vite.config.js 一樣設定 /api 代理,開發時前端只要呼叫相對路徑 /api/products,Vite 會自動轉發到 http://localhost:4022,不會遇到跨來源請求的問題:
// vite.config.js
const apiProxy = {
"/api": {
target: "http://localhost:4022",
changeOrigin: true,
},
};
export default defineConfig({
plugins: [react()],
server: {
proxy: apiProxy,
},
preview: {
proxy: apiProxy, // npm run build + npm run preview 時也要能連到後端
},
});
範例包含前端(Vite)與後端(Express)兩個部分,建議開兩個終端機視窗分別啟動:
server/)cd Day22/examples/day22-router-lab/server
npm install
npm start
啟動成功會看到:
Day22 product API is running at http://localhost:4022
另開一個終端機:
cd Day22/examples/day22-router-lab
npm install
npm run dev
啟動後於瀏覽器開啟 Vite 顯示的網址(預設 http://localhost:5173),即可看到首頁與導覽列。
useLocalStorage 頁面調整計數器數值,重新整理瀏覽器,數值應該還在。useWindowSize 頁面的數字應該即時更新。http://localhost:5173/hello),應該會看到 404 頁面,點擊「回首頁」可以正確導回 /。