前言
中后台系统,说简单也简单,无非就是增删改查、表格表单、权限菜单。但真做起来,坑一点都不少。
过去两年我一直在做 Vue 3 的中后台项目,从零搭过两套,也直接用过网上开源的框架。这篇文章不聊花哨的新特性,就聊中后台场景里最实际的问题:权限怎么做、表格怎么封装、请求怎么管、KeepAlive 怎么不踩坑、动态路由怎么刷新不白屏。
每个点都会给出可落地的代码,希望能帮到同样在做中后台的你。
1. 动态路由 + 菜单权限,别等到刷新才白屏
中后台绕不开权限。常见做法是:登录后拿到用户权限,动态生成路由,再渲染菜单。
但很多人会踩一个坑:刷新页面后动态路由还没加,就跳到了 404。
正确姿势是在全局守卫里处理:
javascript
// router/permission.ts
import router from './index'
import { useUserStore } from '@/stores/user'
import { asyncRoutes } from './routes'
const WHITE_LIST = ['/login', '/404']
router.beforeEach(async (to) => {
const userStore = useUserStore()
if (WHITE_LIST.includes(to.path)) return true
if (!userStore.token) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 已经加载过动态路由,直接放行
if (userStore.routesLoaded) return true
// 拉取用户信息 + 权限
await userStore.fetchUserInfo()
// 根据权限过滤出可访问路由
const accessibleRoutes = filterRoutes(asyncRoutes, userStore.permissions)
accessibleRoutes.forEach((route) => {
router.addRoute(route)
})
// 关键:加一个兜底 404,必须在动态路由之后加
router.addRoute({
path: '/:pathMatch(.*)*',
redirect: '/404',
})
userStore.routesLoaded = true
// 重新导航,确保新加的路由生效
return { ...to, replace: true }
})
几个关键点:
addRoute之后要用return { ...to, replace: true }重新触发导航,否则可能匹配不到- 404 兜底路由一定要最后加,不然动态路由会被它拦截
- 用
routesLoaded标记,避免每次导航都重复添加
2. 按钮级权限:用自定义指令,别到处写 v-if
菜单权限是路由级的,按钮权限就得细到操作级。常见做法是写一个 v-permission 指令。
typescript
// directives/permission.ts
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'
export const permission: Directive<HTMLElement, string[]> = {
mounted(el, binding) {
const userStore = useUserStore()
const required = binding.value
if (!required || required.length === 0) return
const hasPermission = required.some((p) => userStore.permissions.includes(p))
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
},
}
使用:
xml
<template>
<el-button v-permission="['user:add']" type="primary">新增</el-button>
<el-button v-permission="['user:delete']" type="danger">删除</el-button>
</template>
为什么用指令而不是 v-if:
- 语义清晰,一眼能看出这是权限控制
- 不需要在每个组件里都引入 store
- 统一逻辑,改起来只改一个地方
如果权限是异步加载的,记得在权限就绪后再挂载组件,或者用 updated 钩子重新判断。
3. 请求封装:拦截器 + 统一错误处理 + 取消重复请求
中后台的请求量很大,不封装会写到崩溃。
typescript
// utils/request.ts
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const pendingMap = new Map<string, AbortController>()
function getKey(config: any) {
return `${config.method}&${config.url}&${JSON.stringify(config.params)}&${JSON.stringify(config.data)}`
}
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
})
request.interceptors.request.use((config) => {
const userStore = useUserStore()
// 取消重复请求
const key = getKey(config)
if (pendingMap.has(key)) {
pendingMap.get(key)!.abort()
}
const controller = new AbortController()
config.signal = controller.signal
pendingMap.set(key, controller)
// 带上 token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
request.interceptors.response.use(
(response) => {
const key = getKey(response.config)
pendingMap.delete(key)
const { code, data, message } = response.data
if (code === 0) return data
if (code === 401) {
const userStore = useUserStore()
userStore.logout()
window.location.href = '/login'
return Promise.reject(new Error('登录已过期'))
}
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
(error) => {
if (axios.isCancel(error)) return Promise.reject(error)
const key = getKey(error.config ?? {})
pendingMap.delete(key)
ElMessage.error(error.message || '网络异常')
return Promise.reject(error)
}
)
export default request
实践建议:
- 业务 code 和 HTTP status 分开处理
- 401 统一跳登录,别每个页面自己判断
- 重复请求取消,能避免快速切换 tab 时的数据错乱
- 错误提示统一在拦截器里做,页面只管拿数据
4. 表格封装:中后台 80% 的代码都是表格
中后台最费时间的就是表格。建议抽一个 ProTable 组件,把分页、loading、列配置都封装进去。
xml
<!-- components/ProTable/index.vue -->
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, watch } from 'vue'
interface Props {
request: (params: any) => Promise<{ list: T[]; total: number }>
columns: Array<{
prop: string
label: string
width?: number
slot?: string
}>
immediate?: boolean
}
const props = withDefaults(defineProps<Props>(), {
immediate: true,
})
const list = ref<T[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const searchParams = ref<Record<string, any>>({})
async function loadData() {
loading.value = true
try {
const res = await props.request({
page: page.value,
pageSize: pageSize.value,
...searchParams.value,
})
list.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}
function search(params: Record<string, any>) {
searchParams.value = params
page.value = 1
loadData()
}
function reset() {
searchParams.value = {}
page.value = 1
loadData()
}
if (props.immediate) loadData()
defineExpose({ loadData, search, reset })
</script>
封装的核心原则:
- 分页、loading、请求这些通用逻辑收进组件
- 列配置用
columns驱动,特殊列用插槽 - 暴露
loadData、search、reset给父组件 - 不要过度封装,业务差异太大的场景允许直接写原生表格
5. 表单:动态表单 + 校验规则集中管理
中后台表单往往字段多、校验复杂。建议用配置驱动。
ts
// config/userForm.ts
import type { FormItemRule } from 'element-plus'
export const userFormRules: Record<string, FormItemRule[]> = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度 3-20 个字符', trigger: 'blur' },
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
}
vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormInstance } from 'element-plus'
import { userFormRules } from '@/config/userForm'
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
email: '',
phone: '',
})
async function submit() {
await formRef.value?.validate()
// 提交逻辑
}
</script>
<template>
<el-form ref="formRef" :model="form" :rules="userFormRules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
</el-form>
</template>
建议:
- 校验规则抽到单独文件,方便复用和测试
- 动态表单用
v-for+ 配置数组渲染 - 提交前统一
validate,不要每个字段单独判断
6. KeepAlive:缓存列表页,但别缓存详情页
中后台经常需要"从列表进详情,返回时列表状态还在"。用 KeepAlive 可以做到,但很容易踩坑。
vue
<!-- App.vue 或 Layout.vue -->
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
ts
// stores/tabs.ts
export const useTabsStore = defineStore('tabs', () => {
const cachedViews = ref<string[]>([])
function addCachedView(name: string) {
if (!cachedViews.value.includes(name)) {
cachedViews.value.push(name)
}
}
function removeCachedView(name: string) {
cachedViews.value = cachedViews.value.filter((v) => v !== name)
}
function clearCachedViews() {
cachedViews.value = []
}
return { cachedViews, addCachedView, removeCachedView, clearCachedViews }
})
关键点:
include匹配的是组件的name,不是路由name。<script setup>里需要用defineOptions({ name: 'UserList' })显式声明- 详情页通常不缓存,关闭 tab 时要记得
removeCachedView - 退出登录要
clearCachedViews,否则下一个用户可能看到上一个用户的数据
vue
<script setup lang="ts">
defineOptions({ name: 'UserList' })
</script>
7. 面包屑和标签页:和路由联动
中后台一般都有顶部标签页(tabs)和面包屑。核心是监听路由变化,维护一个 tabs 列表。
ts
// stores/tabs.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
export interface TabItem {
path: string
title: string
name: string
closable: boolean
}
export const useTabsStore = defineStore('tabs', () => {
const tabs = ref<TabItem[]>([])
const activeTab = ref('')
function addTab(route: RouteLocationNormalized) {
const path = route.path
if (tabs.value.some((t) => t.path === path)) {
activeTab.value = path
return
}
tabs.value.push({
path,
title: (route.meta.title as string) || '未命名',
name: (route.name as string) || '',
closable: path !== '/dashboard',
})
activeTab.value = path
}
function removeTab(path: string) {
const index = tabs.value.findIndex((t) => t.path === path)
if (index === -1) return
tabs.value.splice(index, 1)
if (activeTab.value === path) {
const next = tabs.value[index] || tabs.value[index - 1]
activeTab.value = next?.path ?? '/dashboard'
}
}
return { tabs, activeTab, addTab, removeTab }
})
在布局组件里监听:
ts
watch(
() => route.path,
() => {
if (route.meta.title) {
tabsStore.addTab(route)
}
},
{ immediate: true }
)
注意:
- 关闭当前 tab 时要跳到相邻 tab,别直接跳首页
- 首页/工作台通常不可关闭
- tab 和 KeepAlive 的
cachedViews要同步维护
8. 环境变量和代理:别把后端地址写死在代码里
中后台一般有 dev / test / prod 多套环境。
bash
# .env.development
VITE_API_BASE_URL=/api
VITE_APP_TITLE=XXX 管理系统(开发)
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=XXX 管理系统
ts
// vite.config.ts
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
server: {
proxy: {
'/api': {
target: env.VITE_PROXY_TARGET,
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
}
})
建议:
- 只有
VITE_前缀的变量才会暴露给客户端 - 敏感信息不要放前端环境变量里
- 代理只在开发环境用,生产走 nginx 或网关
9. 大表格性能:虚拟滚动 + 分页 + 按需渲染
中后台经常遇到几千行甚至上万行的表格,直接渲染会卡死。
几个手段:
- 分页:最直接,后端分页,每页 20-50 条
- 虚拟滚动 :必须一次展示大量数据时用,比如
el-table-v2或vue-virtual-scroller - 按需渲染 :展开行、详情弹窗里的表格,用
v-if懒加载 - 避免在表格里写复杂计算:提前在数据层算好
vue
<script setup lang="ts">
import { shallowRef } from 'vue'
// 大列表用 shallowRef,避免深度响应式开销
const bigList = shallowRef<Row[]>([])
async function loadBigList() {
const res = await fetch('/api/big-list')
bigList.value = await res.json()
}
</script>
原则:
- 能分页就分页,虚拟滚动是最后手段
- 大数据用
shallowRef/markRaw - 表格列不要放太多
formatter,能预处理就预处理
10. 构建优化:分包 + CDN + 按需引入
中后台打包体积容易失控,几个实用手段:
ts
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus'],
echarts: ['echarts'],
utils: ['lodash-es', 'dayjs'],
},
},
},
chunkSizeWarningLimit: 1500,
},
})
其他建议:
- UI 库按需引入,别全量注册
- ECharts 按需引入,只注册用到的图表和组件
- 大的第三方库考虑 CDN 外链
- 开启 gzip / brotli 压缩
- 路由懒加载,首屏只加载必要 chunk
ts
// 按需引入 Element Plus
import { ElButton, ElTable, ElForm } from 'element-plus'
写在最后
作者是在一个小公司,就我一个前端,大部分东西只能靠自己研究,踩坑也是自己爬,记录一些觉得有用的东西,如果有不对的地方,欢迎大家指正。