成为全栈·React 管理后台篇·后台骨架:布局、数据路由与分层守卫
后台骨架不只是左边一条侧栏、上面一根顶栏。真正重要的是:用户从哪个入口进来、看到什么菜单、手动输入地址时能不能进入,三者必须说同一种权限语言。

前言
我以前做后台,第一步通常都是把侧栏搭出来。菜单数组配上图标,点击后切换页面,看起来很快就有了"系统"的样子。
但这种骨架特别容易留下一个坑:菜单按角色隐藏了,路由却没有同步限制。普通用户在侧栏里看不到"用户管理",若他从浏览器历史记录、收藏夹或别人发来的链接进入 /users,页面仍然能打开。反过来也可能发生:菜单允许点击,路由守卫却给了 403。
这种问题不是样式问题,而是入口边界分裂。各位看官,这一篇我们就从这道裂缝出发,看看布局、数据路由、动态菜单与三层守卫怎样拼成一副可靠骨架。
本文要解决什么
- 裸页、后台主壳和业务页为什么要分层。
- 登录、后台资格和页面能力为什么不能揉成一个守卫。
- 菜单与路由怎样复用同一个权限判据。
- 路由懒加载在后台里真正节省了什么。
前置阅读:Vite + React + TypeScript:搭起一个有门禁的后台工程
先画访问树,再写 JSX
当前后台的路由可以压缩成下面这棵树:
text
App
├── 裸页
│ ├── /login
│ └── /no-access
└── RequireAuth
└── RequireConsole
└── AdminLayout
├── /dashboard
├── /articles/* → RequireCan(canManageArticles)
├── /comments → RequireCan(canModerateComments)
├── /categories → RequireCan(canManageCategories)
├── /users → RequireCan(canManageUsers)
├── /settings/site → RequireCan(canManageSiteSettings)
└── /profile/* → 后台用户的账号中心

登录页和"无后台资格"页不套 AdminLayout,因为这两类用户本来就不应该看到后台侧栏。通过身份和角色检查后,AdminLayout 才成为稳定主壳,里面的 <Outlet /> 负责切换业务页面。
我更喜欢先画树再写路由。它会迫使我们回答几个很具体的问题:404 应该出现在哪一层?未登录访问不存在的后台地址,是先看 404,还是先去登录?账号中心属于后台主壳,还是所有登录用户的共同能力?当前产品明确只向 editor/admin 开放这套管理应用,因此 /profile/* 也在 RequireConsole 之下,member 登录后会去 /no-access。这些答案若没想清楚,路由表迟早会长成一堆互相打架的条件。
三层守卫,各管一件事
项目把访问判断拆成三个守卫:
| 守卫 | 回答的问题 | 失败去向 |
|---|---|---|
RequireAuth |
用户是否已经恢复登录态 | /login,保留来源地址 |
RequireConsole |
当前角色是否有资格进入管理后台 | /no-access,解释原因 |
RequireCan |
用户是否具备这个页面的具体能力 | /403 |
RequireAuth 还有一个经常被忽略的中间态:应用启动时,会话恢复尚未结束。这时不能直接把"暂时没有 access token"判断成未登录,否则页面先跳登录,刷新成功后又跳回来,界面会闪,来源地址也可能丢失。
tsx
export const RequireAuth = ({ children }: { children: ReactNode }) => {
const location = useLocation()
const bootStatus = useAuthStore((s) => s.bootStatus)
const authed = useAuthStore((s) => Boolean(s.accessToken && s.user))
if (bootStatus !== 'ready') {
return <FullPageLoading label="正在恢复登录状态" />
}
if (!authed) {
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname + location.search }}
/>
)
}
return <>{children}</>
}
来源地址放在 router state,而不是 query string。登录页需要它来回跳,但它不是页面可以分享的业务条件,没有必要暴露在地址栏。这和列表筛选放 URL 的判断刚好相反:筛选条件希望可刷新、可分享;登录回跳只服务这一次导航。
把三个守卫合成一个当然也能写。问题是它最后只能返回一个笼统的"无权限",并把三种恢复动作混在一起:未登录应该去登录,member 没后台资格应该看说明,editor 访问管理员设置才是 403。代码少了十几行,用户却更难知道下一步做什么,我觉得不划算。
菜单与路由必须共用能力函数
菜单项不是直接写 roles: ['admin'],而是挂能力函数:
ts
export const menuItems = [
{
label: '文章管理',
path: '/articles',
icon: FileText,
can: canManageArticles,
},
{
label: '站点设置',
path: '/settings/site',
icon: Settings,
can: canManageSiteSettings,
},
]
路由使用同一个函数:
tsx
<Route
path="/articles"
element={
<RequireCan can={canManageArticles}>
<ArticleListPage />
</RequireCan>
}
/>
这样能力定义只有一个真相源。角色规则改变时,我们修改能力映射,菜单和路由一起变化。若菜单自己判断角色、路由再抄一份数组,两份代码总有一天会漂。
这里要把边界讲清楚:前端守卫并不安全。用户可以改本地代码、直接调用接口,所以真正的授权仍由后端完成。前端守卫的职责,是不展示无效入口、避免用户走进注定失败的流程,并给出正确反馈。按钮级权限和自锁保护,我们放到 M2-12 再讲。
为什么使用数据路由
当前项目使用 createBrowserRouter,并通过 createRoutesFromElements 保留 JSX 的可读性。最初的普通 <BrowserRouter> 足够做页面跳转,但后来文章编辑页需要统一拦截未保存导航,useBlocker 依赖数据路由上下文,因此路由入口升级成了数据路由。
tsx
export const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<App />}>
<Route path="/login" element={<GuestOnly><LoginPage /></GuestOnly>} />
<Route element={<RequireAuth><RequireConsole><AdminLayout /></RequireConsole></RequireAuth>}>
{/* 业务路由 */}
</Route>
</Route>,
),
)
这个演进很有代表性:选路由模式不能只看当前有几个页面,还要看是否需要 loader、action、阻塞导航等路由级能力。我们没有为了"新 API"一开始就把数据加载全搬进路由,TanStack Query 仍负责服务端数据;只启用真正需要的导航能力。
懒加载按访问路径切开代码
登录用户不会用登录页,普通会员不会加载后台管理页,查看文章列表的人也不该先下载 Markdown 编辑器。因此业务页采用 lazy:
tsx
const LoginPage = lazy(() => import('@/pages/login/LoginPage'))
const DashboardPage = lazy(() => import('@/pages/dashboard/DashboardPage'))
const ArticleFormPage = lazy(() => import('@/pages/articles/ArticleFormPage'))
懒加载不等于"每个组件都拆 chunk"。页面边界天然对应用户访问路径,是最容易解释、也最稳定的切分点。按钮、表格这类小组件继续共享,避免产生大量碎片请求。至于编辑器为什么还要单独分包,我们留到 M2-19 用构建数据说话。
404 放在哪一层,也是一种产品判断
项目把 * 兜底路由放在后台主壳内部。这意味着未登录用户访问 /whatever 时,会先经过 RequireAuth 去登录;已经登录且能进入后台的人,才会在主壳里看到"页面不存在"。
另一种做法是把全局 404 放在所有守卫之外。它也合理,适合公开页面很多、错误链接需要直接被搜索引擎识别的网站。但在纯管理应用里,任意未知地址都属于受保护空间的一部分,先确认身份更符合当前产品边界,也不会把后台结构暴露给未登录访客。
同样,响应式侧栏属于布局责任,不应该散落在业务页。桌面端固定侧栏,窄屏切成 Drawer;页面只关心自己的表格和表单。若每个页面都判断一次窗口宽度,迟早会出现这个页面 768px 收起、另一个页面 1024px 收起的割裂。稳定主壳的意义,就是把导航、主题、通知和响应式行为一次性解决,业务页面只填充 <Outlet />。
适用边界
只有管理员一种角色、五个页面以内的小工具,两层守卫甚至一个简单判断就够了。不要看到"三层"就照抄三层。
当系统出现下面任意情况,能力函数和分层守卫开始有价值:
- 同一角色能进入后台,但并非拥有全部页面。
- 菜单、路由和按钮都依赖同一权限规则。
- 登录态需要异步恢复。
- 不同拒绝原因需要不同反馈或恢复路径。
布局也不必为了"后台感"固定成侧栏。真正稳定的骨架是访问关系,而不是某一种视觉形式。
小结
这套后台骨架最终解决的是一致性:裸页和主壳分清入口,三层守卫分清拒绝原因,菜单和路由复用能力函数,页面懒加载顺着访问路径拆包。
各位看官,下次搭后台时不妨先画一棵路由树,再问一句:这个链接从菜单消失以后,用户手动输入地址会发生什么?能把这个问题回答清楚,骨架通常就不会差。
延伸阅读
- Vite + React + TypeScript:搭起一个有门禁的后台工程
- 前端鉴权闭环:内存令牌、刷新旋转与路由守卫
- 权限模型:从认证到 RBAC
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
