路由命名、权限路由、页面传参
- Requirement:路由
path 使用 kebab-case(小写连字符),多单词用 - 分隔。
- Requirement:路径以
/ 开头,子路由不以 / 开头(相对路径)。
- Standard:路径中使用名词,避免动词,如
/users 而非 /getUsers。
// 正确
{ path: '/user-list', name: 'UserList', ... }
{ path: '/order-detail', name: 'OrderDetail', ... }
// 错误
{ path: '/userList', name: 'UserList', ... } // 驼峰
{ path: '/user_list', name: 'UserList', ... } // 下划线
{ path: 'getUserList', name: 'UserList', ... } // 动词
- Requirement:路由
name 使用 PascalCase,与页面组件名对应,如 UserList、OrderDetail。
- Standard:
name 全局唯一,禁止重复。
- Requirement:动态路由的 name 需包含父级语义,如
UserEdit 而非 Edit。
- Standard:
meta 字段统一包含以下约定字段:
| 字段 | 类型 | 说明 |
|---|
| title | string | 页面标题,用于浏览器标签和面包屑 |
| requiresAuth | boolean | 是否需要登录,默认 true |
| permission | string | 权限标识,如 'user:read' |
| icon | string | 菜单图标 |
| keepAlive | boolean | 是否缓存页面,默认 false |
| hidden | boolean | 是否在菜单中隐藏,默认 false |
{
path: '/user',
name: 'User',
component: () => import('@/layouts/BasicLayout'),
meta: { title: '用户管理', icon: 'UserOutlined' },
children: [
{
path: 'list',
name: 'UserList',
component: () => import('@/pages/User/List'),
meta: { title: '用户列表', permission: 'user:read', keepAlive: true },
},
{
path: 'edit/:id',
name: 'UserEdit',
component: () => import('@/pages/User/Edit'),
meta: { title: '编辑用户', permission: 'user:write', hidden: true },
},
],
}
| 反模式 | 问题 | 建议 |
|---|
| path 使用驼峰 | URL 不规范 | 使用 kebab-case |
| name 重复 | 路由跳转冲突 | name 全局唯一 |
| meta 字段随意命名 | 难以统一处理 | 使用约定字段 |
- Standard:采用基于角色的访问控制(RBAC),路由
meta.permission 声明所需权限标识。
- Standard:权限标识格式为
资源:操作,如 user:read、order:write、user:delete。
- Requirement:权限校验在路由守卫中统一处理,禁止在每个页面组件内重复写权限判断。
- Standard:无权限时跳转 403 页面,而非隐藏菜单(防止直接输入 URL 访问)。
// src/router/guards.ts
import type { Router } from 'vue-router';
import { useUserStore } from '@/store/user';
export function setupGuards(router: Router) {
router.beforeEach((to, from, next) => {
const userStore = useUserStore();
// 1. 登录校验
if (to.meta.requiresAuth !== false && !userStore.isLoggedIn) {
next({ name: 'Login', query: { redirect: to.fullPath } });
return;
}
// 2. 权限校验
if (to.meta.permission && !userStore.hasPermission(to.meta.permission)) {
next({ name: 'Forbidden' });
return;
}
next();
});
}
- Standard:根据用户权限动态生成菜单,过滤掉无权限的路由。
- Standard:前端权限只做”显示控制”,后端接口必须做二次鉴权,禁止仅靠前端控制权限。
// 根据权限过滤路由
function filterRoutes(routes, permissions) {
return routes
.filter(route => {
if (route.meta?.permission) {
return permissions.includes(route.meta.permission);
}
return true;
})
.map(route => ({
...route,
children: route.children ? filterRoutes(route.children, permissions) : [],
}));
}
- Standard:页面内按钮级权限通过自定义指令或 Hook 控制,不通过路由。
// React Hook
import { usePermission } from '@/hooks/usePermission';
function UserActions() {
const canEdit = usePermission('user:write');
const canDelete = usePermission('user:delete');
return (
<>
{canEdit && <Button>编辑</Button>}
{canDelete && <Button danger>删除</Button>}
</>
);
}
| 反模式 | 问题 | 建议 |
|---|
| 仅前端隐藏无权限菜单 | 用户可直接输入 URL 访问 | 路由守卫 + 后端双重校验 |
| 每个组件内写权限判断 | 重复代码 | 统一在守卫处理 |
| 权限标识随意命名 | 难以维护 | 统一 资源:操作 格式 |
- Standard:参数传递有三种方式,按场景选择:
| 方式 | 适用场景 | 示例 |
|---|
| 动态路径参数 | 必填、简单标识(ID) | /user/:id |
| Query 参数 | 可选、筛选条件 | /user/list?page=1&status=active |
| State / Params 状态 | 非 URL 可见的临时数据 | 表单草稿、跳转来源 |
- Requirement:路径参数用于必填标识,如 ID,格式为
:paramName。
- Standard:参数在组件内通过路由 API 获取,并做类型校验。
// 路由定义
{ path: '/user/edit/:id', name: 'UserEdit', component: () => import('@/pages/User/Edit') }
// 组件内获取
import { useParams } from 'react-router-dom';
export default function UserEdit() {
const { id } = useParams<{ id: string }>();
// 校验 id
if (!id) return <Navigate to="/user/list" replace />;
}
- Standard:筛选、分页等可分享的状态使用 query 参数,方便 URL 分享和刷新保留。
- Standard:query 参数在组件内解析为数字、布尔等正确类型,禁止直接当字符串使用。
import { useSearchParams } from 'react-router-dom';
function UserList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const status = searchParams.get('status') || 'all';
const updateQuery = (params) => {
setSearchParams({ ...Object.fromEntries(searchParams), ...params });
};
}
- Caution:避免通过路由 state 传递大量数据,仅用于传递轻量的临时状态(如来源页面)。
- Caution:刷新页面会丢失路由 state,不可作为持久化数据传递方式。
// 传递 state
navigate('/user/edit', { state: { from: 'list' } });
// 接收 state
const { state } = useLocation();
const from = state?.from;
| 反模式 | 问题 | 建议 |
|---|
| 用 query 传大段 JSON | URL 过长、难以维护 | 用 state 或全局状态 |
| 用 state 传 ID | 刷新丢失 | ID 用路径参数 |
| query 参数不做类型转换 | 数字比较出错 | 显式转换类型 |
- Requirement:除首页外,所有路由组件必须使用动态导入(懒加载),减小首屏包体积。
- Standard:按业务模块分包,同一模块的路由打包到同一 chunk。
// 正确:动态导入
{
path: '/user',
component: () => import('@/layouts/BasicLayout'),
children: [
{ path: 'list', component: () => import('@/pages/User/List') },
{ path: 'edit/:id', component: () => import('@/pages/User/Edit') },
],
}
// 错误:静态导入
import UserList from '@/pages/User/List';
{ path: '/user/list', component: UserList }
- Standard:使用 webpack/Vite 的
/* webpackChunkName */ 或 /* @vite-ignore */ 注释对 chunk 命名,方便调试。
component: () => import(/* webpackChunkName: "user" */ '@/pages/User/List')
- Standard:懒加载组件必须配合
Suspense(React)或异步组件 loading 组件,避免白屏。
// React
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
const UserList = lazy(() => import('@/pages/User/List'));
function App() {
return (
<Suspense fallback={<Spin />}>
<UserList />
</Suspense>
);
}
| 反模式 | 问题 | 建议 |
|---|
| 路由组件静态导入 | 首屏包体积过大 | 使用动态导入 |
| 懒加载无 loading 兜底 | 切换路由白屏 | 配合 Suspense/loading |
| 所有路由打包到一个 chunk | 无法利用缓存 | 按模块分包 |
- Standard:路由守卫分为三类:
- 全局前置守卫(beforeEach):登录校验、权限校验
- 全局后置钩子(afterEach):页面标题设置、埋点上报
- 组件内守卫:离开页面时的未保存提示
- Requirement:登录校验和权限校验统一在
beforeEach 中实现。
- Standard:守卫逻辑保持简洁,复杂逻辑抽取为独立函数。
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
// 白名单路由直接放行
const whiteList = ['Login', 'Register', 'NotFound'];
if (whiteList.includes(to.name as string)) {
next();
return;
}
// 未登录跳转登录页
if (!userStore.isLoggedIn) {
next({ name: 'Login', query: { redirect: to.fullPath } });
return;
}
// 权限不足跳转 403
if (to.meta.permission && !userStore.hasPermission(to.meta.permission)) {
next({ name: 'Forbidden' });
return;
}
next();
});
- Standard:在
afterEach 中设置页面标题和上报页面浏览埋点。
router.afterEach((to) => {
// 设置页面标题
const title = to.meta.title as string | undefined;
document.title = title ? `${title} - 管理系统` : '管理系统';
// 上报页面浏览埋点
trackPageView({ path: to.fullPath, title });
});
- Standard:表单页面离开时,如果有未保存数据,必须提示用户确认。
import { useBlocker } from 'react-router-dom';
function UserEdit() {
const [isDirty, setIsDirty] = useState(false);
useBlocker(
() => isDirty && !window.confirm('有未保存的更改,确定离开吗?')
);
}
| 反模式 | 问题 | 建议 |
|---|
| 登录校验写在每个组件 | 重复代码、易遗漏 | 统一在 beforeEach |
| 后置钩子中做权限判断 | 已进入页面,体验差 | 权限判断放前置守卫 |
| 表单页面无离开提示 | 数据丢失 | 使用 useBlocker |