同事在群組問:「那個『Q3 報告』的檔案在哪?」
我在檔案頁搜尋 "Q3 報告",篩選類別選「文件」,畫面上剛好兩筆。我把網址列複製下來貼給他。
他回:「你給的網址打開是全部檔案啊,五百多筆。」
我看了一眼自己貼的網址:
https://files.example.com/files
搜尋關鍵字、篩選條件、第幾頁,全都不在網址裡。它們住在元件的 useState 裡(Day 11),網址一複製出去就全部遺失。
順手按了一下瀏覽器的「上一頁」,搜尋條件也不見了。
這件事在 Angular 也會發生——如果我把搜尋條件放在 service 裡的話。而我在 Angular 確實常常這樣做。
但這次修正它的過程,讓我第一次看清楚兩個 Router 在設計上最根本的差別。
先說結論:
之前提過的 loader 是第一個例子。今天會再看到 action,以及它們怎麼跟網址綁在一起。
不過先從兩邊長得最像的地方開始。
Angular:
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'files',
loadComponent: () => import('./files/files.component').then(m => m.FilesComponent),
children: [
{ path: ':id', loadComponent: () => import('./files/file-detail.component').then(m => m.FileDetailComponent) },
],
},
];
React Router:
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('files', 'routes/files.tsx', [
route(':id', 'routes/files.$id.tsx'),
]),
] satisfies RouteConfig;
結構幾乎一樣:路徑、對應的檔案、巢狀的子路由。
有幾個細節不太一樣:
React Router 不用寫 lazy loading。 每個路由檔案在框架模式下會自動切成獨立的 chunk,進到那一頁才下載。Angular 要自己寫 loadComponent。
巢狀路由的插槽。 Angular 的父元件放 <router-outlet />,React Router 的父元件放 <Outlet />,子路由的畫面就出現在那裡。概念完全一樣。
路由參數。 Angular 可以用 inject(ActivatedRoute),或開啟 withComponentInputBinding() 後直接當成 input 收;React Router 在 loader 裡從 params 拿,元件裡用 useParams() 或從 props 拿。
到這裡都還很好對應。差別從網址的查詢參數開始。
回到開場的問題。修法是把搜尋條件從 useState 搬到網址的查詢參數上:
/files?q=Q3+報告&category=doc&page=1
在 React Router 裡,用 useSearchParams 讀寫:
export default function FilesPage({ loaderData }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get('q') ?? '';
function handleCategoryChange(category: string) {
setSearchParams(prev => {
prev.set('category', category);
prev.set('page', '1');
return prev;
});
}
// ...
}
看起來就是把 useState 換成另一個 API 而已。真正的差別在 loader 這一側:
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const q = url.searchParams.get('q') ?? '';
const category = url.searchParams.get('category') ?? 'all';
const page = Number(url.searchParams.get('page') ?? 1);
return { files: await api.searchFiles({ q, category, page }) };
}
loader 直接從網址讀搜尋條件。 網址一變,React Router 就重新跑一次 loader,拿到新資料。
所以整條流程變成:
使用者改篩選 → 網址改變 → loader 重跑 → 畫面拿到新資料
網址成了唯一的資料來源。複製出去的網址帶著所有條件,同事打開,loader 讀到一樣的條件,看到一樣的兩筆。上一頁、下一頁也都對了,因為瀏覽器本來就會記住網址的歷史。
在 Angular,這件事也做得到——queryParamMap 加上 switchMap 去打 API。但那是「你選擇這樣做」;在 React Router 的框架模式,因為 loader 天生就讀網址,把狀態放進網址變成了阻力最小的那條路。
搜尋框的關鍵字也要進網址,但每打一個字就改網址,瀏覽器的歷史紀錄會被塞爆:按一次上一頁只退一個字。
所以兩件事要一起做:先用 Day 11 的 useDebouncedValue 等使用者停手,再更新網址,而且用 replace 取代目前那一筆歷史紀錄,而不是新增一筆:
const debouncedKeyword = useDebouncedValue(inputValue, 300);
useEffect(() => {
setSearchParams(prev => {
prev.set('q', debouncedKeyword);
return prev;
}, { replace: true });
}, [debouncedKeyword]);
Day 11 寫的那個十行 hook,在這裡又派上用場了。
actionAngular Router 完全不碰資料寫入。表單送出時,元件呼叫 service,service 打 API,成功之後,自己決定要重抓哪些資料、要不要導頁。
React Router 的框架模式多了一個 action:
// routes/files.$id.tsx
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
await api.renameFile(params.id, String(formData.get('name')));
return { ok: true };
}
export default function FileDetail({ loaderData }: Route.ComponentProps) {
return (
<Form method="post">
<input name="name" defaultValue={loaderData.file.name} />
<button type="submit">儲存</button>
</Form>
);
}
<Form> 送出時,React Router 會呼叫這個路由的 action。
最關鍵的是後面這一步:action 結束之後,React Router 會自動把目前畫面上所有的 loader 重跑一次。
檔名改完,詳情頁的 loader 重跑、外層檔案列表的 loader 也重跑,兩邊顯示的都是新名字。我什麼都不用做。
這跟 invalidateQueries 在解決同一個問題:資料改了,誰該更新?TanStack Query 的答案是「你告訴我哪些 key 失效了」,React Router 的答案是「畫面上看得到的,全部重新抓一次」。後者比較粗,但完全不用想。
也可以在 action 結束後導頁:
import { redirect } from 'react-router';
export async function action({ params }: Route.ActionArgs) {
await api.deleteFile(params.id);
return redirect('/files');
}
對應 Angular 在 service 回來之後呼叫 router.navigate(['/files'])。
loader 跟 TanStack Query,到底誰負責?寫到這裡,loader 會讀網址、會在 action 之後自動重跑;TanStack Query 會快取、會去重、會在分頁切回來時重抓。兩個都在處理伺服器狀態,難免會想:只留一個不行嗎?
我最後的分工是這樣:
loader 負責「進這一頁就該有的資料」,而且跟網址綁在一起。 檔案列表、檔案詳情,這些資料由網址決定,就讓 loader 讀網址去抓。
TanStack Query 負責「跨頁共用、或頁面上互動才需要的資料」。 頁首的空間用量每一頁都看得到,放在任何一個 loader 裡都不對;檔案詳情頁上「點了才展開」的版本歷史,也不屬於進頁時就該載入的東西。
兩者也可以接在一起。如果專案關掉 SSR、走 SPA 模式(Day 15),路由用的是在瀏覽器端執行的 clientLoader,可以直接讓它先把資料放進 TanStack Query 的快取:
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
return queryClient.ensureQueryData({
queryKey: ['files', params.id],
queryFn: () => api.getFile(params.id),
});
}
export default function FileDetail() {
const { id } = useParams();
const { data: file } = useQuery({
queryKey: ['files', id],
queryFn: () => api.getFile(id!),
});
// ...
}
ensureQueryData 的意思是:快取裡有就直接用,沒有才去抓。clientLoader 讓資料在進頁前就準備好,元件裡的 useQuery 用同一把 key,拿到的是快取裡的同一份,不會再打一次 API。之後的去重、背景更新、失效,全部交給 TanStack Query。
注意這裡的 queryClient 可以是模組層級的單例。Day 14 說過要用 useState 包起來,是因為 SSR 時伺服器會讓所有使用者共用同一個模組;SPA 模式沒有伺服器端渲染,這個顧慮就不存在了。同一個寫法,在不同部署模式下,安全與否不一樣。
開 SSR 的話接法比較複雜,需要在伺服器上預先抓資料、再把快取「搬」到瀏覽器,這超出今天的範圍。我們的系統是內網後台,選了 SPA 模式,這個問題剛好避開了。
| Angular Router | React Router(框架模式) | |
|---|---|---|
| 路由設定 | app.routes.ts |
app/routes.ts |
| 子路由插槽 | <router-outlet /> |
<Outlet /> |
| 連結 | routerLink、routerLinkActive |
<Link>、<NavLink> |
| 程式導頁 | router.navigate() |
useNavigate(),或 action 裡 redirect() |
| 延遲載入 | 自己寫 loadComponent |
每個路由檔自動切分 |
| 路由參數 | ActivatedRoute 或 input binding |
params、useParams() |
| 查詢參數 | queryParamMap |
useSearchParams()、loader 讀 request.url |
| 進頁前取資料 | resolver | loader、clientLoader |
| 資料寫入 | 不管,交給 service | action + <Form> |
| 寫入後更新 | 自己決定重抓什麼 | 自動重跑畫面上的 loader |
| 導航中狀態 | 監聽 router events | useNavigation() |
| 守衛 | canActivate 等 |
loader 裡檢查後 redirect(Day 26 細談) |
兩個 Router 的路由設定長得幾乎一樣,真正的差別在範圍。
Angular Router 是一塊路標:告訴你這個網址要顯示哪個元件,然後就退場了。資料、表單、更新,都是 service 的工作。
React Router 的框架模式把路標、資料、寫入綁在一起:loader 從網址讀條件去抓資料,action 處理寫入,寫完自動讓 loader 重跑。網址不再只是「現在在哪一頁」,而是這一頁的狀態本身。
開場那個網址分享出去就失效的 bug,在 Angular 是因為我習慣把狀態放進 service;在 React 是因為我還沒習慣把狀態放進網址。兩邊都做得到,只是框架模式讓後者變得比較順手。