系統裡有一頁「系統設定」,只有管理員能用。我照著需求,把側邊欄的這個選項包了一層判斷:
{isAdmin && <NavLink to="/admin/settings">系統設定</NavLink>}
隔天,一位一般權限的同事在群組貼了一張截圖:他把管理員傳給他的網址 /admin/settings 直接貼進瀏覽器,頁面完整地打開了,所有設定欄位都看得到,還能編輯。
選單藏起來了,但門根本沒鎖。
在 Angular,這種事我從來不會漏,因為路由設定裡一定會掛一個 guard。到了 React Router,我竟然忘了這一層。
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;
}
沒有權限,就在畫面出現之前導走。使用者根本看不到設定頁長什麼樣子。
權限從哪裡來,我們分成兩層:
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。