iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 19 篇

Day 19|分享出去的網址,搜尋條件不見了

  • 分享至 

  • xImage
  •  

同事在群組問:「那個『Q3 報告』的檔案在哪?」

我在檔案頁搜尋 "Q3 報告",篩選類別選「文件」,畫面上剛好兩筆。我把網址列複製下來貼給他。

他回:「你給的網址打開是全部檔案啊,五百多筆。」

我看了一眼自己貼的網址:

https://files.example.com/files

搜尋關鍵字、篩選條件、第幾頁,全都不在網址裡。它們住在元件的 useState 裡(Day 11),網址一複製出去就全部遺失。

順手按了一下瀏覽器的「上一頁」,搜尋條件也不見了。

這件事在 Angular 也會發生——如果我把搜尋條件放在 service 裡的話。而我在 Angular 確實常常這樣做。

但這次修正它的過程,讓我第一次看清楚兩個 Router 在設計上最根本的差別。


一、兩個 Router,管的範圍不一樣

先說結論:

  • Angular Router 管的是「去哪裡」。 路徑對應到哪個元件,就這樣。資料怎麼抓、表單怎麼送,是 service 跟元件的事。
  • React 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 天生就讀網址,把狀態放進網址變成了阻力最小的那條路。

跟 debounce 一起用的小細節

搜尋框的關鍵字也要進網址,但每打一個字就改網址,瀏覽器的歷史紀錄會被塞爆:按一次上一頁只退一個字。

所以兩件事要一起做:先用 Day 11 的 useDebouncedValue 等使用者停手,再更新網址,而且用 replace 取代目前那一筆歷史紀錄,而不是新增一筆:

const debouncedKeyword = useDebouncedValue(inputValue, 300);

useEffect(() => {
  setSearchParams(prev => {
    prev.set('q', debouncedKeyword);
    return prev;
  }, { replace: true });
}, [debouncedKeyword]);

Day 11 寫的那個十行 hook,在這裡又派上用場了。


四、路由也管資料寫入:action

Angular 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 是因為我還沒習慣把狀態放進網址。兩邊都做得到,只是框架模式讓後者變得比較順手。


上一篇
Day 18|一筆資料是 null,整個系統白掉
下一篇
Day 20|測試不再問元件,改問畫面
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言