iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

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

Day 26|選單藏起來了,網址貼上還是進得去

  • 分享至 

  • xImage
  •  

系統裡有一頁「系統設定」,只有管理員能用。我照著需求,把側邊欄的這個選項包了一層判斷:

{isAdmin && <NavLink to="/admin/settings">系統設定</NavLink>}

隔天,一位一般權限的同事在群組貼了一張截圖:他把管理員傳給他的網址 /admin/settings 直接貼進瀏覽器,頁面完整地打開了,所有設定欄位都看得到,還能編輯。

選單藏起來了,但門根本沒鎖。

在 Angular,這種事我從來不會漏,因為路由設定裡一定會掛一個 guard。到了 React Router,我竟然忘了這一層。


一、Angular 的 guard:路由上的門鎖

Angular 的寫法很直接,函式型 guard 掛在路由上:

export const adminGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.hasRole('admin') ? true : router.createUrlTree(['/forbidden']);
};
{
  path: 'admin',
  canActivate: [adminGuard],
  children: [
    { path: 'settings', loadComponent: () => import('./settings.component') },
  ],
}

除了 canActivate,Angular 還有一整組:canMatch 決定這條路由要不要被匹配、canActivateChild 保護所有子路由、canDeactivate 在離開頁面前攔下來。

每一種都有名字、有固定的位置。我只要選對那一個,掛上去就好。

React Router 沒有這組名字。那這些門鎖,要掛在哪裡?


二、先說最重要的一件事:前端擋不住任何人

在講 React 怎麼寫之前,要先說清楚一件事。這件事在 Angular 也成立,只是 guard 用起來太順手,很容易讓人忘記。

前端的權限檢查,是使用者體驗,不是安全機制。

不管是 Angular 的 guard 還是等一下要寫的 React 版本,所有程式碼都在使用者的瀏覽器裡執行。懂一點技術的人,打開開發者工具就能改掉判斷、直接呼叫 API。

真正的門鎖在後端。我們的 Spring Boot 那邊,管理員的 API 本來就該這樣保護或是有其他權限控管機制:

@PreAuthorize("hasRole('ADMIN')")
@PutMapping("/api/admin/settings")
public SettingsDto updateSettings(@RequestBody SettingsDto dto) { ... }

回頭查了一下,那位同事雖然打開了頁面,但按下儲存時,後端回了 403。資料是安全的。 只是他看到了一個不該看到的畫面,還可以在上面填一堆注定送不出去的資料。

所以前端的 guard 要做的事,是讓使用者不要走進一扇打不開的門:早點告訴他沒有權限,而不是讓他填完表單才失敗。

這件事想清楚了,接下來要怎麼寫就清楚了。


三、路由層:在 clientLoader 裡檢查

React Router 的路由會在進頁之前跑 loader(我們是 SPA 模式,所以是 clientLoader)。既然它在畫面出現之前執行,那就是放門鎖最自然的地方:

// routes/admin.settings.tsx
import { redirect } from 'react-router';

export async function clientLoader() {
  const permissions = await queryClient.ensureQueryData({
    queryKey: ['me', 'permissions'],
    queryFn: () => meApi.getPermissions(),
  });

  if (!permissions.includes('settings:manage')) {
    throw redirect('/forbidden');
  }

  return null;
}

沒有權限,就在畫面出現之前導走。使用者根本看不到設定頁長什麼樣子。

權限從哪裡來,我們分成兩層:

  • 粗的角色,例如管理員、編輯者、檢視者,直接從 Azure AD 的 token 裡讀。MSAL 登入後,account.idTokenClaims.roles 就有使用者的角色。
  • 細的權限,例如「能不能刪除檔案」,由後端的 /api/me/permissions 回傳。這份資料用 Day 19 的 ensureQueryData 放進 TanStack Query 的快取,所有頁面共用,只打一次 API。

如果不想導頁,而是想在原地顯示「你沒有權限檢視這個頁面」,可以改成丟出 403,交給 Day 18 的路由 ErrorBoundary:

if (!permissions.includes('settings:manage')) {
  throw data('沒有權限', { status: 403 });
}

導頁還是原地顯示,看產品怎麼設計。我們選了原地顯示,因為導頁之後網址就變了,使用者會不知道自己剛剛想去哪裡。


四、保護一整群路由

管理員底下不只一頁。每一頁的 clientLoader 都寫一次檢查,很快就會有人漏掉。

Angular 會把 guard 掛在父路由上,所有子路由一起保護。React Router 也能這樣做:建立一個 layout 路由,在它的 clientLoader 裡檢查。

// routes.ts
layout('routes/admin.layout.tsx', [
  route('admin/settings', 'routes/admin.settings.tsx'),
  route('admin/users', 'routes/admin.users.tsx'),
]),

但這裡有一個陷阱。同一次導航裡,各層路由的 loader 是同時出發的。 也就是說,父路由還在檢查權限的同時,子路由的 clientLoader 已經開始打 API 了。父路由判定沒權限、導走了,但子路由那支 API 還是送出去了。

後端會擋下來,所以不會出事,但這不是我們要的行為。

較新版本的 React Router 為此提供了 middleware(目前需要開啟 future flag,依版本而定)。它跟 loader 不同,是從父到子依序執行的,而且在所有 loader 之前:

// routes/admin.layout.tsx
export const clientMiddleware: Route.ClientMiddlewareFunction[] = [
  async () => {
    const permissions = await queryClient.ensureQueryData({ /* 同上 */ });
    if (!permissions.includes('admin:access')) {
      throw redirect('/forbidden');
    }
  },
];

父路由的 middleware 擋下來,子路由的 loader 根本不會執行。這就是 Angular canActivate 的行為:門沒開,就不會有人走進去。

如果你的版本還沒有 middleware,退而求其次的做法是讓每個子路由的 clientLoader 都呼叫同一個 requirePermission() 函式,至少檢查邏輯只寫一次。


五、元件層:按鈕要不要出現

路由擋住了「能不能進這一頁」。但一頁之內,還有更細的權限:檢視者看得到檔案列表,但不該看到「刪除」按鈕。

在 Angular,我會寫一個結構型指令:

<button *hasPermission="'file:delete'">刪除</button>

React 沒有指令,但有 hook:

// lib/permissions.ts
export function usePermission(permission: string) {
  const { data: permissions = [] } = useQuery({
    queryKey: ['me', 'permissions'],
    queryFn: () => meApi.getPermissions(),
  });
  return permissions.includes(permission);
}
function FileRow({ file }: FileRowProps) {
  const canDelete = usePermission('file:delete');

  return (
    <tr>
      <td>{file.name}</td>
      {canDelete && <td><DeleteButton fileId={file.id} /></td>}
    </tr>
  );
}

用的是跟路由層同一把 queryKey,所以拿到的是同一份快取,不會再打一次 API。

也有人會包成元件,寫起來更像 Angular 的指令:

<Can permission="file:delete">
  <DeleteButton fileId={file.id} />
</Can>

兩種都可以。我們用 hook,因為很多時候權限不只決定「出不出現」,還會決定「能不能點」或「顯示什麼文字」,hook 拿到一個布林值,用法比較彈性。

至於第三層——資料本身,例如「每個人只能看到自己部門的檔案」——那一層完全是後端的事。後端回什麼,前端就顯示什麼。


六、離開前攔下來:canDeactivate 的對應

Angular 的 guard 裡,有一個我很常用的:canDeactivate。使用者在 Day 17 那個三十個欄位的表單裡改了一半,不小心點到側邊欄的另一個連結,所有修改就不見了。

React Router 對應的是 useBlocker:

function FileMetaEditor({ file }: Props) {
  const form = useForm({ /* ... */ });
  const isDirty = form.formState.isDirty;

  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) =>
      isDirty && currentLocation.pathname !== nextLocation.pathname,
  );

  return (
    <>
      <form>{/* ... */}</form>
      {blocker.state === 'blocked' && (
        <ConfirmDialog
          message="有尚未儲存的修改,確定要離開嗎?"
          onConfirm={() => blocker.proceed()}
          onCancel={() => blocker.reset()}
        />
      )}
    </>
  );
}

表單有改動、又要離開這一頁時,導航會被攔下來,blocker.state 變成 'blocked'。使用者確認離開就呼叫 proceed(),取消就 reset()。

注意 isDirty 是 Day 17 提過的 React Hook Form 狀態。Proxy 的訂閱機制會讓這個元件在 isDirty 變化時更新,useBlocker 才拿得到最新的值。

useBlocker 只管得到 React Router 內部的導航。使用者直接關掉分頁或重新整理,要另外監聽瀏覽器的 beforeunload 事件。這一點 Angular 的 canDeactivate 也一樣,它同樣攔不到關分頁。


七、對照一下

Angular React Router
進頁前檢查 canActivate clientLoader 裡 throw redirect()
保護子路由 父路由的 canActivate 或 canActivateChild layout 路由的 clientMiddleware
執行順序 依序,guard 不過子路由不載入 loader 同時出發;middleware 才是依序
沒權限的畫面 導到指定頁 導頁,或丟 403 給 ErrorBoundary
元件層權限 結構型指令 *hasPermission usePermission() hook 或 <Can>
離開前確認 canDeactivate useBlocker
真正的安全 後端 後端

今天的結論

選單藏起來不等於門鎖上了。而門鎖上了,也不等於安全——那把真正的鎖,一直都在後端。

這件事在 Angular 也成立,只是 guard 用起來太順手,讓我很少停下來想清楚前端的權限檢查到底在保護什麼。答案是:它在保護使用者的體驗。 讓沒有權限的人早點知道,不要讓他走進一扇打不開的門。

想清楚這點之後,React Router 的寫法就很自然了:進頁前的檢查放在 clientLoader,要保護一整群路由就用 middleware,一頁之內的細節用 hook。Angular 給每一種情況一個名字,React Router 則是用幾個通用的機制組出來。

唯一要特別注意的,是 loader 會同時出發這件事。Angular 的 guard 是一道真正的門,沒開就沒人能進去;React Router 的 loader 比較像是同時派好幾個人去敲門。要真正依序,得用 middleware。


上一篇
Day 25|如何優雅處理 Token 失效?
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言