
一句话:前端权限的定位是「体验优化,不是安全边界」——它的正确做法是「后端下发权限点(不是角色),前端用权限点做三件事:路由守卫、菜单过滤、按钮/字段级控制」,而最容易出错的地方是「把『无权限』和『404』混为一谈」以及「动态路由带来的『白屏、闪烁、缓存失效』」。
一、先立规矩:前端权限能做什么、不能做什么
⭐⭐⭐⭐ 前端权限的两条铁律(面试必答):
① 【前端权限「不是安全边界」】⭐
前端做的所有权限判断(隐藏按钮、拦截路由)**都能被绕过**:
· 用户可以用 DevTools 改 JS 变量
· 可以直接调接口(curl/Postman)
· 可以改 URL 强行访问
→ ⭐ **真正的校验必须在后端**(每个接口都要校验「这个用户能不能做这件事」)
→ 前端的权限只是「**让用户看不到/点不到他不该用的东西**」(体验 + 减少无效操作)
② 【用「权限点」而不是「角色」做判断】⭐
❌ 前端到处写 `if (user.role === 'admin')`
→ 问题:加一个「财务」角色就要改前端所有地方;
而且「管理员能做的一切」与「某个人只有部分管理权限」无法区分
✅ 后端下发**权限点数组**(如 `['product:read', 'product:write', 'order:refund']`)
→ 前端只判断「有没有这个权限点」:
`if (can('order:refund')) { ... }`
⭐ 收益:「角色 → 权限点」的映射在**后端/配置中心**维护,
前端代码**不需要因「组织架构调整」而改动**。
⭐⭐⭐ 权限控制的三种粒度(缺一个都会漏):
① 【路由级】——「能不能进这个页面」
✅ 在「路由守卫」里判断(⭐ 在「渲染之前」——见 6.11 的 beforeLoad)
⚠️ 只做路由级不够:「有权限进页面」不代表「页面里每个按钮都能点」
② 【组件/元素级】——「这个按钮 / 这一列 / 这个字段 显不显示」
✅ 用 `<Can permission="order:refund">…</Can>` 包裹,或 `can()` 判断
⚠️ 注意「只隐藏不拦截」的反例:如果后端不校验,用户直接调接口依然能改
③ 【数据级】——「这一行数据我能不能看 / 改」
⭐ 这**必须在后端做**(前端拿到的数据要么「已经过滤好」,要么「带一个 canEdit 标记」)
⚠️ 前端拿全量数据再过滤 = 「用户能在 Network 面板里看到不该看的数据」
✅ 正确做法:后端按权限返回「字段/行」的可见性与可写性
(如「返回 `salary: null` + `salaryVisible: false`」,而不是「返回后前端隐藏」)
二、权限模型:RBAC 与权限点
// ============ 1. 后端下发的「用户信息」(含权限点) ============
interface CurrentUser {
id: string;
name: string;
/** ⭐ 角色(仅用于展示/分组,不用于「前端判断」) */
roles: string[];
/** ⭐⭐ 权限点(前端所有判断都基于它) */
permissions: string[];
}
// 一个真实项目的权限点设计(⭐ 命名规范:`资源:动作`,可带「范围」)
const PERMISSIONS = {
PRODUCT_READ: 'product:read',
PRODUCT_WRITE: 'product:write',
PRODUCT_DELETE: 'product:delete',
ORDER_READ: 'order:read',
ORDER_REFUND: 'order:refund',
// ⭐ 「数据范围」也可以编码进权限点(或单独一个字段)
ORDER_READ_ALL: 'order:read:all', // 能看「所有人」的订单
ORDER_READ_SELF: 'order:read:self', // 只能看「自己」的订单
USER_MANAGE: 'user:manage',
SETTINGS_BILLING: 'settings:billing',
} as const;
type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
// ============ 2. 权限判断的核心:一个 Context + 三个 API ============
import { createContext, useContext, useMemo, useCallback } from 'react';
interface AuthContextValue {
user: CurrentUser | null;
isLoggedIn: boolean;
/** ⭐ 核心判断(支持「单个 / 数组(任一) / 函数」) */
can: (requirement: Permission | Permission[] | ((p: (x: Permission) => boolean) => boolean)) => boolean;
/** ⭐ 要求「全部满足」 */
canAll: (requirements: Permission[]) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ user, children }: { user: CurrentUser | null; children: React.ReactNode }) {
const permissionSet = useMemo(() => new Set(user?.permissions ?? []), [user?.permissions]);
const has = useCallback((p: Permission) => permissionSet.has(p), [permissionSet]);
const can = useCallback<AuthContextValue['can']>(
(requirement) => {
if (typeof requirement === 'function') return requirement(has);
if (Array.isArray(requirement)) return requirement.some(has); // ⭐ 数组 = 「任一」
return has(requirement);
},
[has]
);
const canAll = useCallback((reqs: Permission[]) => reqs.every(has), [has]);
const value = useMemo<AuthContextValue>(
() => ({ user, isLoggedIn: Boolean(user), can, canAll }),
[user, can, canAll]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth 必须在 <AuthProvider> 内使用');
return ctx;
}
/** ⭐ 便捷 Hook:只取 can */
export function usePermission() {
const { can, canAll } = useAuth();
return { can, canAll };
}
// ============ 3. 元素级:<Can> 组件(比散落的 if 更可读) ============
interface CanProps {
/** ⭐ 需要的权限(单个 / 数组=任一) */
permission?: Permission | Permission[];
/** ⭐ 需要全部 */
all?: Permission[];
/** 无权限时的替代内容(默认不渲染) */
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function Can({ permission, all, fallback = null, children }: CanProps) {
const { can, canAll } = useAuth();
const allowed = (() => {
if (all?.length) return canAll(all);
if (permission === undefined) return true;
return can(permission);
})();
if (!allowed) return <>{fallback}</>;
// ⭐ 注意「只隐藏不拦截」的边界:
// 如果一个「操作按钮」被隐藏但接口没校验 → 用户仍能通过接口完成
// ⭐ 所以 <Can> 是「体验优化」,真正的安全在后端
return <>{children}</>;
}
// 用法:一个「订单行」的操作按钮
function OrderRowActions({ order, onRefund, onView }: { order: Order; onRefund: () => void; onView: () => void }) {
return (
<div>
<button onClick={onView}>查看</button>
{/* ⭐ 只有「有退款权限」才显示退款按钮 */}
<Can permission={PERMISSIONS.ORDER_REFUND}>
<button onClick={onRefund}>退款</button>
</Can>
{/* ⭐ 禁用而不是隐藏(当「功能存在但当前对象不允许」时) */}
<Can
permission={PERMISSIONS.ORDER_REFUND}
fallback={<button disabled title="你没有退款权限">退款</button>}
>
<button onClick={onRefund}>退款</button>
</Can>
</div>
);
}
⭐⭐⭐ 「隐藏」还是「禁用」?——一个常被搞混的产品决策:
【隐藏】✅ 适合「这个用户永远不会用到的功能」
(如「财务」在运营的界面上完全不显示)
【禁用 + 提示原因】✅ 适合「功能存在,只是当前条件不满足」
(如「有退款权限,但这个订单已超出退款期」)
⭐ 判据:「**用户知道『这个功能存在』会不会有帮助**」——
会(如「他看到别人能退款,知道自己缺权限,去找管理员开通」)→ 禁用 + 说明
不会(只会增加困惑)→ 隐藏
⚠️ 两种都不影响「后端必须校验」这条铁律。
三、路由级:静态路由 vs 动态路由
⭐⭐⭐ 「动态路由」(后端下发菜单 → 前端生成路由)的取舍:
【静态路由(路由表写在前端,用权限「过滤菜单」)】⭐ 推荐
✅ 简单、可控、构建时就能做代码分割
✅ 「404 页面」与「无权限」容易区分
✅ 类型安全(路径是字面量)
⚠️ 新增页面需要「前端发版」
⭐ 适合:绝大多数后台系统(菜单与权限点「一一对应」)
【动态路由(后端返回菜单树,前端动态 addRoute)】
✅ 「后端配置菜单,前端不用发版」(⭐ 这是它唯一的强收益)
⚠️ 代价很大:
· 「路由组件」要「按字符串动态映射」(`component: 'UserList'` → 一个映射表)
⭐ 且「动态导入 + 类型安全」都会变难
· 首屏「白屏」风险(要先拿菜单再挂路由)
· 「404」与「无权限」难区分(路由根本不存在 vs 存在但没权限)
· 「缓存/预加载/代码分割」都变复杂
· 前端「路由结构」被后端的配置格式绑架
⭐ 适合:「菜单项极多且频繁变化」(如「平台化 SaaS」「大后台」)
—— 但即便这样,也常做成「静态路由 + 后端只控制『哪些菜单显示』」
// ✅ 推荐做法:静态路由表 + 权限过滤(而不是「动态生成路由」)
interface AppRoute {
path: string;
/** ⭐ 访问所需权限(不写 = 登录即可) */
permission?: Permission;
/** 是否需要登录 */
requiresAuth?: boolean;
/** 菜单显示信息(不写 = 不在菜单里显示,如「详情页」) */
menu?: { title: string; icon?: string; order?: number; hidden?: boolean };
children?: AppRoute[];
}
const routes: AppRoute[] = [
{
path: '/',
menu: { title: '仪表盘', icon: 'dashboard', order: 0 },
// 登录即可
},
{
path: '/products',
permission: PERMISSIONS.PRODUCT_READ,
menu: { title: '商品管理', icon: 'box', order: 10 },
children: [
{ path: '/products/new', permission: PERMISSIONS.PRODUCT_WRITE, menu: { title: '新建商品' } },
// ⭐ 「详情页」不进菜单(hidden)
{ path: '/products/:id', permission: PERMISSIONS.PRODUCT_READ },
],
},
{
path: '/orders',
permission: [PERMISSIONS.ORDER_READ_ALL, PERMISSIONS.ORDER_READ_SELF], // ⭐ 任一
menu: { title: '订单', icon: 'list', order: 20 },
},
{
path: '/settings',
permission: PERMISSIONS.USER_MANAGE,
menu: { title: '设置', icon: 'gear', order: 90 },
children: [
{ path: '/settings/users', permission: PERMISSIONS.USER_MANAGE, menu: { title: '用户管理' } },
{ path: '/settings/billing', permission: PERMISSIONS.SETTINGS_BILLING, menu: { title: '账单' } },
],
},
];
/** ⭐ 菜单树:由权限过滤(「父节点没权限 → 整棵子树不显示」) */
function buildMenu(routes: AppRoute[], can: (p: Permission | Permission[]) => boolean) {
const out: Array<{ title: string; path: string; icon?: string; children?: unknown[] }> = [];
for (const route of routes) {
if (!route.menu || route.menu.hidden) continue; // ⭐ 不进菜单的跳过
// ⭐ 权限过滤(父级不通过 → 子级也不显示)
if (route.permission && !can(route.permission)) continue;
const children = route.children ? buildMenu(route.children, can) : undefined;
// ⚠️ 细节:如果「父节点本身是分组、没有实际页面」,且「子节点全都被过滤掉了」
// → 就不该显示这个空分组
if (route.children?.length && (!children || children.length === 0)) continue;
out.push({
title: route.menu.title,
path: route.path,
icon: route.menu.icon,
children: children?.length ? children : undefined,
});
}
return out.sort((a, b) => {
const oa = findOrder(routes, a.path) ?? 0;
const ob = findOrder(routes, b.path) ?? 0;
return oa - ob;
});
}
function findOrder(routes: AppRoute[], path: string): number | undefined {
for (const r of routes) {
if (r.path === path) return r.menu?.order;
if (r.children) {
const found = findOrder(r.children, path);
if (found !== undefined) return found;
}
}
return undefined;
}
四、完整实现:路由守卫 + 菜单 + 403/404 区分(250 行)
// auth-routing.tsx —— 一个完整的「权限路由」实现
import { useMemo, useCallback } from 'react';
import { createBrowserRouter, Navigate, Outlet, useLocation, useRouteError, isRouteErrorResponse } from 'react-router-dom';
/** ============ 1. 权限判断(复用上面的 useAuth) ============ */
// (省略,见上文 AuthProvider / useAuth)
/** ============ 2. 一个「守卫」组件:渲染前判断 ⭐ ============ */
/**
* ⭐⭐ 关键设计:守卫「不渲染 children」直到权限确定,
* 避免「先渲染受保护内容再跳走」(闪一下 + 可能的请求泄漏)。
* ⚠️ 与 TanStack Router 的 beforeLoad 等价的东西在 React Router 里没有,
* 所以只能「用组件包裹」—— 这也是「TanStack Router 更干净」的一个体现。
*/
function RequireAuth({ permission, children }: { permission?: Permission | Permission[]; children: React.ReactNode }) {
const { isLoggedIn, can, user } = useAuth();
const location = useLocation();
// ⭐ ① 「会话还在恢复中」(如「用 refresh token 换 access token」)
// → 渲染 loading(⭐ 而不是「先跳登录页再跳回来」——那样会闪烁)
if (user === undefined) {
return <FullPageSpinner label="正在恢复登录状态…" />;
}
// ⭐ ② 未登录 → 去登录页(带上「我想去哪」)
if (!isLoggedIn) {
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
}
// ⭐③ 已登录但无权限 → 明确的 403(⭐ 不是 404、也不是「跳首页」)
if (permission && !can(permission)) {
return <ForbiddenPage requiredPermission={permission} />;
}
return <>{children}</>;
}
/** ============ 3. 403 / 404 的明确区分 ⭐ ============ */
function ForbiddenPage({ requiredPermission }: { requiredPermission?: Permission | Permission[] }) {
return (
<div role="alert" style={{ padding: 40, textAlign: 'center' }}>
<h1>403 · 没有访问权限</h1>
你的账号没有访问该页面的权限。
{/* ⭐ 明确告诉用户「缺什么权限」——这对他找管理员开通很有帮助 */}
{requiredPermission && (
<p style={{ color: '#888', fontSize: 12 }}>
需要的权限:{Array.isArray(requiredPermission) ? requiredPermission.join(' 或 ') : requiredPermission}
</p>
)}
<a href="/">返回首页</a>
</div>
);
}
function NotFoundPage() {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<h1>404 · 页面不存在</h1>
链接可能已失效,或者你输入了错误的地址。
<a href="/">返回首页</a>
</div>
);
}
/** ⭐ 路由错误边界:把「路由层的错误」与「业务错误」分开 */
function RouteErrorBoundary() {
const error = useRouteError();
// ⭐ React Router 的「路由错误」(404、loader 抛错)
if (isRouteErrorResponse(error)) {
if (error.status === 404) return <NotFoundPage />;
if (error.status === 403) return <ForbiddenPage />;
return <div role="alert">加载失败:{error.status} {error.statusText}</div>;
}
return <div role="alert">出错了:{String(error)}</div>;
}
/** ============ 4. 路由表(⭐ 权限在「路由定义」里声明,而不是散在组件里) ============ */
function buildRouter() {
return createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{
// ⭐⭐⭐ 应用外壳(所有受保护路由的父级)
path: '/',
element: (
<RequireAuth>
<AppLayout>
<Outlet />
</AppLayout>
</RequireAuth>
),
errorElement: <RouteErrorBoundary />,
children: [
{ index: true, element: <DashboardPage /> },
{
path: 'products',
element: (
// ⭐ 子路由「再包一层」(细粒度权限)
<RequireAuth permission={PERMISSIONS.PRODUCT_READ}>
<Outlet />
</RequireAuth>
),
errorElement: <RouteErrorBoundary />,
children: [
{ index: true, element: <ProductListPage /> },
{
path: 'new',
element: (
<RequireAuth permission={PERMISSIONS.PRODUCT_WRITE}>
<ProductNewPage />
</RequireAuth>
),
},
{ path: ':id', element: <ProductDetailPage /> },
],
},
{
path: 'orders',
element: (
<RequireAuth permission={[PERMISSIONS.ORDER_READ_ALL, PERMISSIONS.ORDER_READ_SELF]}>
<OrderListPage />
</RequireAuth>
),
},
{ path: 'settings/contributing', element: <RequireAuth permission={PERMISSIONS.SETTINGS_BILLING}><BillingPage /></RequireAuth> },
// ⭐ 兜底 404(放在最后)
{ path: '*', element: <NotFoundPage /> },
],
},
]);
}
/** ============ 5. 一个「会做数据级权限」的列表页(⭐ 关键在「后端返回可写标记」) ============ */
interface OrderRow {
id: string;
amount: number;
status: 'pending' | 'paid' | 'refunded';
/** ⭐⭐ 后端返回「这一行『我』能不能操作」——而不是前端「凭角色猜」 */
canRefund: boolean;
canViewDetail: boolean;
}
function OrderListPage() {
const { can } = useAuth();
const { data, isLoading } = useOrders(); // 数据层(见 7.4)
if (isLoading) return <TableSkeleton rows={8} />;
return (
<table>
<thead>
<tr>
<th>订单号</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{data?.items.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.amount}</td>
<td>{row.status}</td>
<td>
{/* ⭐⭐⭐ 数据级:用「后端给的行级标记」,而不是「前端再判断一次」 */}
<button disabled={!row.canViewDetail}>查看</button>
<button disabled={!row.canRefund}>退款</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
⭐⭐⭐ 这套实现里的五个「关键决定」:
① 【守卫「在渲染前」判断(`RequireAuth` 直接 `return <Navigate>`)】
⭐ 效果:「未登录/无权限时,受保护的组件【一次都不渲染】」——
避免「闪一下受保护内容」+「那一帧里泄漏请求」。
② 【明确区分「会话恢复中 / 未登录 / 无权限」(三种状态)】
⭐ 这是「体验」的关键:
· 「会话还在恢复」→ 渲染 loading(**不要**先跳登录页)
· 「未登录」→ 跳登录页(带 `redirect`)
· 「已登录但无权限」→ **403 页面**(不是 404、也不是「静默跳首页」)
⚠️ 把三者混在一起是最常见的错误(用户会「莫名其妙回到首页」)。
③ 【403 与 404 分开】
404 = 「这个路径不存在」(路由没匹配到)
403 = 「路径存在,但你没权限」
⭐ 混为一谈的后果:用户「不知道该找管理员开权限,还是该检查链接」
⚠️ 但也有团队「故意把 403 显示成 404」(避免泄露「某页面存在」)——
这是**安全 vs 体验**的取舍,⭐ 需要**明确决策并写进文档**。
④ 【菜单由「同一份路由表 + 权限」生成】
⭐ 好处:「菜单」与「路由」不会不一致
(否则会出现「菜单里有但点进去 403」或「能访问但菜单没入口」)
⚠️ 注意「空分组」的处理(父节点没权限 / 子节点全被过滤 → 不显示分组)。
⑤ 【数据级权限靠「后端返回行级标记」】
`canRefund` / `canViewDetail` 由后端算好 →
⭐ 好处:前端不需要「复制一份权限规则」(规则只在后端一处)
⚠️ 反例:「前端拿全量数据 + 前端按角色过滤」→
① 规则重复维护;② **敏感数据已经在 Network 里可见**(安全漏洞)。
五、本篇特有的坑
// ① 用「角色」做前端判断(加角色就要改前端到处是 if)
if (user.role === 'admin') { ... } // ❌
// ✅ if (can('product:write')) { ... }(角色→权限点的映射在后端)
// ② 把「无权限」当成「跳首页」(用户不知道发生了什么)
if (!can('x')) return <Navigate to="/" />; // ⚠️ 静默跳走
// ✅ 明确的 403 页面
// ③ 「无权限」与「404」混为一谈(用户不知道该怎么办)
// ✅ 分开(或「明确决定要合并」并写进文档)
// ④ 「未登录」时「先渲染再跳」(闪一下 + 可能发请求)
useEffect(() => { if (!isLoggedIn) navigate('/login'); }, []); // ❌
// ✅ 在渲染前 return <Navigate />
// ⑤ 「会话恢复中」也当「未登录」(→ 用户看到「登录页闪一下」)
// ⚠️ 症状:刷新页面时「先跳登录页,再跳回来」
// ✅ 用「三态」:恢复中(loading)/ 未登录 / 已登录
// ⑥ 登录后没跳回原地址(用户还得自己再点一次)
// ✅ `?redirect=...` + 登录成功后校验「是否同源」再跳(⭐ 防开放重定向)
// ⑦ 「菜单」与「路由表」各写一份(必然不一致)
// ✅ 同一份数据源生成(见上文的 buildMenu)
// ⑧ 「空分组」还显示(父节点无权限但菜单里留了个空壳)
// ✅ 过滤后为空就不显示
// ⑨ 「隐藏按钮」但「接口没校验」(安全漏洞)⭐
// ⭐ 这是最常见的「假权限」
// ✅ 每个接口都要校验(前端 <Can> 只是体验)
// ⑩ 「前端过滤数据行」(敏感数据已下发)
const visible = orders.filter((o) => can('order:read:all') || o.userId === me.id); // ⚠️
// ✅ 后端按权限返回(行/字段级)
// ⑪ 把「权限点」编码成「数字位运算」(难读、难维护)
const FLAG_REFUND = 1 << 3; // ⚠️
// ✅ 用字符串(`order:refund`)—— 可读、可搜索、可配
// ⑫ 「权限点」粒度太粗(`admin` 一个点走天下)
// ✅ 按「资源:动作」(必要时加「范围」:`order:read:all` / `order:read:self`)
// ⑬ 「权限点」粒度太细(几百个点,配置困难)
// ✅ 平衡点:「一个页面/一个操作对应 1~2 个点」
// ⑭ 「权限变更后」前端不刷新(管理员改了权限,用户还在旧状态)
// ✅ 提供「刷新权限」的入口 + 「401/403 时拉一次最新用户信息」
// ⑮ 动态路由的「白屏」(要先拿菜单再挂路由)
// ⭐ 这是动态路由的主要代价
// ✅ 若必须用:先渲染「应用外壳 + loading」,菜单到了再挂子路由
// ⑯ 动态路由的「路由组件靠字符串映射」(类型不安全 + 难以代码分割)
component: 'UserList' // ⚠️
// ✅ 用「静态路由表 + 权限过滤」(推荐)
// ⑰ 「权限判断」写在「渲染热路径」上(每次都遍历大数组)
permissions.includes(p) // ⚠️ 数组 O(n)
// ✅ 用 Set(O(1))+ useMemo 缓存
// ⑱ 「403 页面」上没有「我该找谁」的信息
// ✅ 显示「需要的权限」+「联系管理员」入口(⭐ 减少支持成本)
// ⑲ 「前端权限」当成「安全措施」写进设计文档 ⭐
// ⭐ 这会「误导后来者」
// ✅ 文档里明确写「前端权限 = 体验优化;安全校验在后端」
// ⑳ 「权限点命名」混乱(`readProduct` / `product_read` / `PRODUCT-READ`)
// ✅ 统一规范(推荐 `资源:动作[:范围]` 小写冒号分隔)
// ⑥ 的完整处理(防「开放重定向」)
function LoginPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const redirectTo = params.get('redirect');
const onSuccess = useCallback(() => {
// ⭐⭐ 必须校验「是不是站内路径」(否则 `?redirect=https://evil.com` 就是钓鱼入口)
const target = safeRedirect(redirectTo);
void navigate(target, { replace: true });
}, [redirectTo, navigate]);
return <LoginForm onSuccess={onSuccess} />;
}
function safeRedirect(raw: string | null): string {
if (!raw) return '/';
try {
// ⭐ 用「相对当前 origin 解析」再比较 origin
const url = new URL(raw, window.location.origin);
if (url.origin !== window.location.origin) return '/';
// ⚠️ 额外防护:拒绝「协议相对 URL」(`//evil.com`)
if (raw.startsWith('//')) return '/';
return url.pathname + url.search + url.hash;
} catch {
return '/';
}
}
// ⑭ 的完整处理(权限变更后刷新)
// ① 在「axios 拦截器」里:遇到 403 → 拉一次最新用户信息
axios.interceptors.response.use(undefined, async (error) => {
if (error.response?.status === 403) {
// ⭐ 可能是「权限变了」→ 刷新用户信息(含权限点)
await authStore.refreshUser();
}
if (error.response?.status === 401) {
// ⭐ 未登录/过期 → 清态 + 跳登录(⭐ 不要在这里「自动重试」造成死循环)
authStore.logout();
}
return Promise.reject(error);
});
// ② 提供「手动刷新权限」入口(放在用户菜单里)
// ③ 「权限点变化」时(如管理员在另一个标签页改了)→ 见 3.14 的跨标签页同步
六、面试延伸
- 「前端怎么做权限控制?」
三层粒度 + 一条铁律。铁律:⭐ 前端权限不是安全边界(能改 JS、能直接调接口、能改 URL),真正的校验必须在后端——前端做的是「体验优化(让用户看不到/点不到不该用的东西)+ 减少无效操作」。三层粒度:① 路由级——在「渲染之前」判断(React Router 用包裹组件、TanStack Router 用beforeLoad),未登录 → 登录页、已登录但无权限 → 403;② 元素级——<Can permission="order:refund">或can(),⚠️ 并明确「隐藏 vs 禁用」的产品决策;③ ⭐ 数据级——必须后端做(返回「行/字段」的可见性与可写性标记,如canRefund),❌ 前端拿全量再过滤会导致「敏感数据已在 Network 可见」。 -
「为什么用『权限点』而不是『角色』?」
因为「角色是组织的概念,权限点才是代码该依赖的东西」。用角色的问题:① 前端到处if (user.role === 'admin')→ 加一个新角色就要改前端所有地方;② 「角色」粒度太粗——「都是管理员,但财务只能看账单、运营只能看订单」无法区分;③ 「组织架构调整」会引发前端代码改动。用权限点的好处:⭐ 「角色 → 权限点」的映射在后端/配置中心维护,前端只判断「有没有这个点」(can('order:refund'))——于是「组织调整」不需要改前端。命名规范建议「资源:动作[:范围]」小写冒号分隔(如order:read:all)。⚠️ 粒度要平衡(太粗一个admin走天下、太细几百个点难配置),实践上「一个页面/一个操作对应 1~2 个点」。 -
「
403和404该怎么区分?」
语义上必须分清:404 = 「这个路径不存在」(路由没匹配到);403 = 「路径存在,但你没权限」。⭐ 混为一谈的后果是「用户不知道该找管理员开权限,还是该检查链接」,支持成本上升。三种处理策略:① 分开(推荐)——403 页面明确写「你缺少order:refund权限」+「联系管理员」入口,⭐ 减少支持成本;② ⭐ 故意把 403 显示成 404——用于「不想泄露『某页面存在』」的安全场景(如「内部管理后台的入口」),这是安全 vs 体验的取舍;③ 「无权限 → 静默跳首页」❌——最差(用户完全不知道为什么)。⭐ 关键是要「明确决策并写进文档」,而不是「随手写一个」。 -
「『未登录』和『无权限』的守卫怎么写才不闪?」
关键是「在渲染之前判断」:① ⭐ React Router——用「包裹组件」RequireAuth,直接return <Navigate to="/login">(不渲染 children)——但注意它不能阻止「父路由已经渲染」,所以「外壳(导航栏)」仍可能出现;② ⭐ TanStack Router——用beforeLoad里throw redirect(...),路由的组件根本不会被渲染(更干净);③ ⭐ 三态处理(最重要):「会话恢复中」→ loading /「未登录」→ 登录页 /「已登录但无权限」→ 403。⚠️ ❌ 最常见错误是把「会话恢复中」也当成「未登录」→ 用户刷新页面时「先看到登录页闪一下,再跳回来」。 -
「动态路由(后端返回菜单)值得做吗?」
⭐ 大多数情况不值得。它唯一的强收益是「后端配菜单,前端不用发版」。代价很大:① 路由组件要靠字符串映射(component: 'UserList')→ 类型不安全 + 代码分割困难;② 首屏白屏风险(要先拿菜单再挂路由,⭐ 常见做法是「先渲染外壳 + loading」);③ ⭐ 「404」与「无权限」难区分(「路由根本没注册」vs「注册了但没权限」);④ 缓存/预加载/TS 类型都变复杂;⑤ 前端路由结构被后端的配置格式绑架。推荐做法:⭐ 「静态路由表 + 权限过滤菜单」——路由写在前端(可控、可类型检查、可代码分割),后端只控制「权限点」(进而决定「哪些菜单显示」)。唯一例外:「菜单项极多且频繁变化的平台化 SaaS」——但即便这样也常做成「静态路由 + 后端只控制菜单显示」。 -
「菜单和路由怎么保持一致?」
⭐ 用「同一份数据源」:定义一份「路由表(含path/permission/menu元信息)」,然后:① 路由注册从它生成;② 菜单树用「同一份数据」经过「权限过滤」生成;③ 面包屑/标题/权限也从它读。好处:⭐ 不会出现「菜单里有但点进去 403」或「能访问但菜单没入口」。两个细节:① 「不进菜单」的路由要显式标记(详情页、新建页——用menu.hidden或「不写menu」);② ⭐ 「空分组」要摘掉——「父节点没权限」或「子节点全被过滤」时,不该留下一个空壳分组(⚠️ 这是很常见的体验缺陷)。 -
「前端权限的『安全建议』有哪些?」
五条:① ⭐ 所有接口都必须校验权限(后端是唯一的安全边界)——前端隐藏按钮只影响体验;② ⭐ 敏感数据不要在「下发给前端后再过滤」——要么「后端按权限返回」(行/字段级),要么「返回时就不带上」(如「不可见时返回salary: null+salaryVisible: false」);③ ⭐ 防开放重定向——?redirect=...必须校验「是否同源」(否则是「钓鱼入口」);④ token 存储——优先HttpOnlyCookie(JS 读不到,⭐ 减少 XSS 窃取,见 8.2);⑤ ⭐ 把「前端权限 = 体验优化」写进设计文档——避免后来者误以为「前端拦住了就是安全的」。另外:「403 时拉一次最新用户信息」(应对「权限被后台改了」)、「权限点用字符串而不是位运算」(可读可配)。 -
「如果后端只有『角色』,前端怎么办?」
分三种情况:① ⭐ 能推动后端改——最好(让后端下发权限点,前端只判断点);② 暂时不能改——在前端建一层「角色 → 权限点」的映射表(⭐ 集中在一处,如rolesToPermissions.ts),并在代码里只用权限点判断(can('order:refund')内部去查映射)——这样「以后后端改成下发权限点时,只改这一处」;③ 连映射都做不了(如「权限规则极其动态,前端无法静态描述」)——那就把判断交给后端:让后端在「页面数据接口」里返回「行级/字段级的可操作标记」(canRefund),前端只渲染后端给的标记(⭐ 这是最稳的:规则只在后端一处,且天然安全)。
一句话速记
⭐ 铁律:前端权限不是安全边界(能改 JS / 直接调接口 / 改 URL)——真正的校验在后端,前端做的是「体验优化 + 减少无效操作」;⭐ 用「权限点」不用「角色」(
order:refund,命名资源:动作[:范围])——「角色 → 权限点」的映射在后端维护,组织调整不改前端;三层粒度:路由级(在「渲染之前」判断,RequireAuth直接return <Navigate>/ TanStackbeforeLoad抛redirect)+ 元素级(<Can>,并明确「隐藏 vs 禁用」的产品决策)+ ⭐ 数据级(必须后端返回「行/字段级可操作标记」,❌ 前端拿全量再过滤 = 敏感数据已在 Network 可见);⭐ 必须区分三种状态:「会话恢复中 → loading(❌ 当成未登录会导致「刷新页面先闪登录页」)/ 未登录 → 登录页(带redirect,⭐ 必须校验同源防开放重定向)/ 已登录但无权限 → 403」,且⭐ 403 与 404 要分清(403 显示「缺什么权限 + 找谁开通」,或用 404 掩盖存在性——要明确决策并写文档);⭐ 动态路由大多不值得(组件靠字符串映射、首屏白屏、404 与无权限难区分、类型与代码分割变复杂)——✅ 推荐「静态路由表 + 用同一份数据源生成菜单(权限过滤)」(⚠️ 记得摘掉「空分组」);菜单与路由必须同一份数据源(否则「菜单有但 403」或「能访问但没入口」);403 时拉一次最新用户信息(应对权限被后台改);⭐ 把「前端权限 = 体验优化」写进文档。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!