1. 后台管理系统的技术选型与整体设计思路
后台管理系统这东西,做过三个以上项目的人都会有一个共同感受:难的不是某个页面写不出来,而是几十个页面写出来之后还能保持一致、还能被同事接手、还能在半年后自己看得懂。Vue3 加 Element Plus 这套组合之所以在中后台领域站得这么稳,原因不在于它有多炫,而在于它把“重复劳动”这件事压到了最低。我手上的项目大多是数据密集型的 CRM、运营后台、设备管理平台,这类系统的共同点是:表格多、表单多、权限多、字段多,而 Element Plus 的表格和表单能力刚好卡在这个需求上,Vue3 的 Composition API 又让逻辑复用变得顺手。
整篇内容我会按“选型判断 → 环境搭建 → 骨架设计 → 业务页面 → 样式适配 → 构建部署 → 问题排查”的顺序往下走,每一步都给出可直接复制的代码和踩过的坑。适合三类人:刚学完 Vue3 基础想找个完整项目练手的、从 Vue2 迁移过来想搞清楚差异的、以及正在搭公司新后台脚手架需要参考方案的。你不需要对 Vue3 了如指掌,但至少要知道ref和reactive是干什么的。
1.1 三件套各自解决什么问题
很多人把 Vue3、Vite、Element Plus 当成一个整体来记,其实它们职责完全不同,分清楚之后选型和排错都会快很多。
Vue3 负责的是数据驱动视图这一层。它的核心变化在于响应式系统从Object.defineProperty换成了Proxy,带来的直接好处是:新增属性、删除属性、数组下标赋值都能被侦测到,不再需要Vue.set那一套补丁。同时 Composition API 让逻辑可以按功能拆分而不是按选项类型拆分,一个“列表查询”的逻辑可以完整地写在一个useTable函数里,这在后台系统里价值极大。
Vite 负责的是开发与构建。它的原理是开发环境用浏览器原生 ES Module 直接加载模块,不做整体打包,所以冷启动从 Webpack 时代的三四十秒降到一两秒。生产环境仍然走 Rollup 打包,所以产物质量并不差。这里有个常见误解:Vite 快只是开发快,构建速度取决于 Rollup 的配置和依赖体积,项目大了照样要几十秒。
Element Plus 负责的是视觉与交互组件。它是 Element UI 的 Vue3 重写版,底层用 TypeScript 重写,支持按需引入和主题变量定制。表格、表单、弹窗、树、穿梭框这些后台高频组件它都有,而且 API 设计和 Vue2 版本差异不算大,迁移成本低。
1.2 主流 UI 框架横向对比
选 UI 框架不能只看组件数量,得看你的项目形态。下面这张表是我根据实际用过的几个框架整理的,仅代表中后台场景下的主观感受。
| 框架 | 设计风格 | 组件完整度 | 主题定制难度 | 适合场景 | 我的实际体验 |
|---|---|---|---|---|---|
| Element Plus | 中庸偏稳重 | 很高,表格能力突出 | 低,CSS 变量友好 | 中后台、表单密集 | 默认样式偏“企业味”,业务方接受度高 |
| Ant Design Vue | 精致、规整 | 很高,生态成熟 | 中,需要理解设计 token | 复杂中后台、大团队 | 规范性强,但体积和心智负担也大 |
| Naive UI | 现代、清爽 | 高,TS 类型优秀 | 低,主题对象式配置 | 中小型项目、个人项目 | 写起来舒服,社区组件偏少 |
| Arco Design Vue | 现代、留白多 | 高 | 中 | 数据可视化后台 | 视觉好看,团队熟悉度需要时间 |
选 Element Plus 的核心理由是表和表单够用且稳。后台系统百分之七十的工作量在这两个组件上,Element Plus 的el-table支持多级表头、固定列、树形数据、虚拟滚动(需额外引入),el-form的校验规则体系也很成熟。如果你的项目是重展示、轻交互的官网或者 C 端页面,那选 Naive UI 或者 Arco 会更出彩。
1.3 不同规模项目的架构取舍
架构这词听起来大,落到实际就是三个问题:路由怎么分、状态放哪、请求怎么封装。
十来个页面的小后台,我建议扁平路由 + 页面内直接发请求,别过早抽象。我见过一个项目总共八个页面,非要搞动态权限路由加 Pinia 分模块,最后新人看一眼 store 目录就劝退。小而浅的项目,抽象成本反而高于收益。
三十到一百个页面的中型后台,标准做法是:路由按业务模块拆文件,Pinia 按领域拆 store,请求统一走封装的 axios 实例,权限用动态路由 + 按钮指令两层控制。
超过一百个页面的平台级系统,一般会考虑微前端或者多仓库。这时候单仓库单应用会带来构建时间爆炸、多人协作冲突的问题。不过微前端不是银弹,路由跳转、状态共享、样式隔离都会带来新麻烦,量级没到就别碰。
注意:架构选型一定要看团队平均水平和项目预期寿命。三年内就会重构的项目,别上重量级方案;预期存活五年的核心系统,第一次就把抽象层搭对。
2. 从零搭环境:Vue3 工程初始化的每一步
环境这一步,新手最容易被版本问题卡住。我自己在 Windows 和 macOS 上都踩过 Node 版本不匹配导致 Vite 启动报错的坑,所以这里把版本选择、包管理器、初始化命令和目录规划都讲清楚。
2.1 Node 版本与包管理器的选择
Vue3 项目对 Node 的最低要求通常是 16 以上,但 Vite 4 之后建议 Node 18 起步,Vite 5 建议 Node 18.17 或者 20 以上。原因是构建工具本身用了新语法和新的 API,低版本 Node 会直接报错退出。
推荐用 nvm 或者 fnm 来管理 Node 版本,这样一台机器上可以同时存在多个版本,切换项目不用重装。Windows 用户可以直接装 nvm-windows,命令基本一致。
# 查看当前版本 node -v npm -v # 安装并使用 Node 20 nvm install 20 nvm use 20包管理器方面,npm、pnpm、yarn 都能用。我个人在中后台项目里优先选 pnpm,理由有两个:一是磁盘占用小,多个项目共享同一个依赖副本;二是依赖安装严格,不会因为幽灵依赖导致“本地能跑线上跑不了”。但要注意,pnpm 默认不提升依赖,某些老库可能需要配置shamefully-hoist才能正常引入。
# 安装 pnpm npm install -g pnpm # 查看版本 pnpm -v实操心得:团队协作项目一定要在根目录放
.npmrc和engines字段,把 Node 版本和包管理器版本写死,否则每次有人报“我本地跑不起来”,排查成本都极高。
2.2 Vite 创建项目与初始化
创建项目直接用官方脚手架,交互里选择 Vue + TypeScript 组合即可。
pnpm create vite admin-demo --template vue-ts cd admin-demo pnpm install pnpm dev这里有个细节值得说:vue和vue-ts两个模板的区别只在于是否带了 TypeScript 配置。后台管理系统我强烈建议用 TS。表格列定义、接口返回类型、表单字段类型这些东西用 TS 描述之后,改字段时编译器会直接告诉你哪些地方漏改了,这个收益在项目超过二十个页面后非常明显。
如果你想更省事,也可以用pnpm create vue@latest,这是官方维护的交互式脚手架,可以选择是否带 Router、Pinia、ESLint、Prettier,一步到位。
启动之后默认端口是 5173,如果被占用会自动递增。想固定端口就在vite.config.ts里配置。
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'node:path' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 8080, open: true, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, '') } } } })配置@别名之后,还必须在tsconfig.json里补上对应的paths,否则 TS 会报找不到模块。
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }2.3 目录结构规划
目录结构这件事,没有绝对正确的答案,但有几个原则必须守:按职责分层,按业务分模块,公共的东西必须集中。下面这套是我用了几个项目之后固定下来的结构。
src/ ├── api/ # 接口定义,按业务模块拆文件 │ ├── user.ts │ └── order.ts ├── assets/ # 静态资源,需要被打包的 ├── components/ # 全局通用组件 │ └── ProTable/ ├── composables/ # 组合式函数 │ ├── useTable.ts │ └── useDialog.ts ├── directives/ # 自定义指令,如权限指令 ├── layout/ # 布局框架 ├── router/ # 路由配置与守卫 ├── stores/ # Pinia 状态仓库 ├── styles/ # 全局样式与变量 ├── utils/ # 工具函数,如 request.ts ├── views/ # 页面,按业务模块建子目录 │ ├── dashboard/ │ ├── system/ │ └── order/ ├── App.vue └── main.ts关键在composables和components这两个目录。后台系统里“一个带搜索、分页、增删改查的表格页”会被重复写几十遍,把这些逻辑抽成useTable,把表格外壳抽成ProTable组件,能让每个列表页的代码从三百行降到八十行。
2.4 Element Plus 的按需引入与自动导入
全量引入 Element Plus 会让打包体积增加好几百 KB,虽然能跑,但首屏会明显变慢。正确做法是按需自动导入,用unplugin-vue-components和unplugin-auto-import两个插件。
pnpm add element-plus pnpm add -D unplugin-vue-components unplugin-auto-import// vite.config.ts import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ imports: ['vue', 'vue-router', 'pinia'], resolvers: [ElementPlusResolver()], dts: 'src/auto-imports.d.ts' }), Components({ resolvers: [ElementPlusResolver()], dts: 'src/components.d.ts' }) ] })配置好之后,模板里直接写<el-table>就行,不需要import,也不需要手动app.use(ElementPlus)。自动生成的.d.ts文件记得提交到仓库,不然其他同事拉下来会报类型错误。
注意:Element Plus 的消息提示类组件(
ElMessage、ElMessageBox、ElLoading)属于函数式调用,样式不会自动引入。需要额外用ElementPlusResolver({ importStyle: 'sass' })配合unplugin-auto-import来处理,或者手动在main.ts里引入对应样式文件。
3. 骨架搭建:路由、状态与权限体系
骨架是后台系统的地基。这部分写得好,后面加页面就是填空题;写得不好,每加一个页面都要改三四处公共代码。我按路由、状态、请求、权限四个维度来讲。
3.1 路由分层设计与动态路由生成
路由分两种来源:一种是静态路由,登录页、404 页、布局框架这些不依赖权限的;另一种是动态路由,根据用户角色从后端拉取菜单后动态注册。
静态路由写在router/index.ts里,动态路由的关键在于router.addRoute的调用时机和重复添加问题。
// router/index.ts import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' export const constantRoutes: RouteRecordRaw[] = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue') }, { path: '/404', name: 'NotFound', component: () => import('@/views/error/404.vue') } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes, scrollBehavior: () => ({ top: 0 }) }) export default router动态路由的做法通常是后端返回菜单树,前端用一份“组件路径到真实组件”的映射表来转换。这里有个坑:import()里不能写完全动态的变量,Vite 无法静态分析会导致打包失败。正确写法是用import.meta.glob提前收集所有页面组件。
// router/dynamic.ts const modules = import.meta.glob('@/views/**/*.vue') export function buildRoutes(menus: MenuItem[]): RouteRecordRaw[] { return menus.map((m) => ({ path: m.path, name: m.name, component: modules[`/src/views${m.component}.vue`], meta: { title: m.title, icon: m.icon, keepAlive: m.keepAlive } })) }刷新页面的问题是动态路由的经典难点。因为路由是运行时添加的,浏览器一刷新,内存里的路由就没了,结果就是白屏或者跳到 404。解决办法有两种:一是在全局守卫里判断路由表是否已就绪,没就绪就重新拉菜单、重新添加,然后next({ ...to, replace: true })重新进入一次;二是直接把菜单持久化,启动时同步恢复。
// router/guard.ts import router from './index' import { useUserStore } from '@/stores/user' router.beforeEach(async (to, from, next) => { const userStore = useUserStore() const token = userStore.token if (!token) { if (to.path === '/login') return next() return next(`/login?redirect=${to.path}`) } if (to.path === '/login') return next('/') if (userStore.routesReady) return next() try { await userStore.loadRoutes() next({ ...to, replace: true }) } catch (e) { userStore.logout() next('/login') } })next({ ...to, replace: true })这一句是精髓。它让导航重新匹配一次,此时动态路由已经注册好了,才能正确命中目标页面。少了这一句,就会一直停在白屏或者 404。
3.2 Pinia 状态管理落地
Pinia 是 Vue3 官方推荐的状态库,相比 Vuex 有三个明显好处:没有 mutation 概念、对 TS 类型推导友好、可以按需组合。
后台系统里真正需要放进全局 store 的东西其实不多,我一般只放四类:用户信息与 token、权限路由与按钮权限、全局配置(主题、语言、折叠状态)、全局字典缓存。
// stores/user.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { loginApi, getUserInfoApi, getMenusApi } from '@/api/user' import { buildRoutes } from '@/router/dynamic' import router from '@/router' export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref<UserInfo | null>(null) const menus = ref<MenuItem[]>([]) const permissions = ref<string[]>([]) const routesReady = ref(false) const roles = computed(() => userInfo.value?.roles ?? []) async function login(payload: LoginForm) { const { data } = await loginApi(payload) token.value = data.token localStorage.setItem('token', data.token) } async function loadRoutes() { const { data } = await getMenusApi() menus.value = data.menus permissions.value = data.permissions const routes = buildRoutes(data.menus) routes.forEach((r) => router.addRoute('Layout', r)) router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' }) routesReady.value = true } function logout() { token.value = '' userInfo.value = null menus.value = [] permissions.value = [] routesReady.value = false localStorage.removeItem('token') } return { token, userInfo, menus, permissions, roles, routesReady, login, loadRoutes, logout } })Pinia 用 setup 函数风格写,好处是逻辑和组件写法完全一致,不需要再学一套state/getters/actions的心智模型。且router.addRoute('Layout', r)的第二个参数指定父路由名,这样动态路由会挂到布局下面,侧边栏才能正确渲染。
3.3 登录鉴权与请求拦截
请求封装几乎是每个后台项目都要重写一遍的东西。核心诉求有五个:自动带 token、统一处理错误码、401 自动登出、请求取消、loading 状态管理。
// utils/request.ts import axios from 'axios' import type { AxiosInstance, AxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' interface ApiResult<T = unknown> { code: number data: T message: string } const service: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 15000 }) service.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token && config.headers) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) service.interceptors.response.use( (response) => { const res = response.data as ApiResult if (res.code === 200) return res if (res.code === 401) { const userStore = useUserStore() userStore.logout() router.replace('/login') return Promise.reject(new Error('登录已过期')) } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, (error) => { if (axios.isCancel(error)) return Promise.reject(error) const msg = error.response?.status === 500 ? '服务端异常' : error.message ElMessage.error(msg) return Promise.reject(error) } ) export function request<T>(config: AxiosRequestConfig): Promise<ApiResult<T>> { return service.request(config) } export default service这里要注意useUserStore()必须写在拦截器回调内部,不能写在模块顶层。因为 Pinia 实例是在app.use(pinia)之后才可用的,顶层调用会直接报错。
3.4 按钮级权限指令
菜单级权限只是第一层,真实项目里经常出现“同一个页面,不同角色能点的按钮不一样”。做法是自定义指令v-permission。
// directives/permission.ts import type { Directive } from 'vue' import { useUserStore } from '@/stores/user' export const permission: Directive<HTMLElement, string[] | string> = { mounted(el, binding) { const userStore = useUserStore() const need = Array.isArray(binding.value) ? binding.value : [binding.value] const has = need.some((p) => userStore.permissions.includes(p)) if (!has) { el.parentNode?.removeChild(el) } } }使用的时候直接写v-permission="['order:delete']",没权限的按钮会从 DOM 里被移除。这里强调一点:前端权限只是体验层的过滤,真正的安全必须在后端校验。前端把按钮藏起来,只是让用户不去点不该点的东西,用开发者工具改一改还是能发请求,所以后端接口必须独立鉴权。
4. 核心业务页面实操
骨架搭完之后,剩下的工作是批量生产页面。这一节我给出三个高频页面的可复用模板。
4.1 Layout 布局与侧边栏递归菜单
后台布局基本是固定的:左侧菜单、顶部导航、中间内容区、可选的面包屑和标签页。
el-menu的递归渲染要写一个子组件MenuItem.vue,判断当前项有没有children,有就继续递归自己。
<!-- layout/components/MenuItem.vue --> <template> <template v-for="item in list" :key="item.path"> <el-sub-menu v-if="item.children?.length" :index="item.path"> <template #title> <el-icon><component :is="item.icon" /></el-icon> <span>{{ item.title }}</span> </template> <MenuItem :list="item.children" /> </el-sub-menu> <el-menu-item v-else :index="item.path"> <el-icon><component :is="item.icon" /></el-icon> <template #title>{{ item.title }}</template> </el-menu-item> </template> </template> <script setup lang="ts"> defineProps<{ list: MenuItem[] }>() </script>这里:index我习惯直接用完整路径,然后在el-menu上绑定:default-active="$route.path"。这样刷新页面时高亮状态能自动恢复,不需要额外维护一个 activeIndex 状态。
内容区配合router-view和keep-alive,把需要缓存的页面用meta.keepAlive标记。要注意keep-alive的include匹配的是组件name,用<script setup>的组件默认没有 name,可以用defineOptions({ name: 'OrderList' })显式声明。
4.2 通用表格页模板
这是后台系统使用频率最高的模板。我把查询条件、分页、表格、操作按钮全部封装进一个ProTable组件,业务页面只需要传columns和request函数。
<!-- components/ProTable/index.vue --> <template> <div class="pro-table"> <el-form :model="searchForm" inline> <slot name="search" :form="searchForm" /> <el-form-item> <el-button type="primary" :loading="loading" @click="handleSearch">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form> <el-table v-loading="loading" :data="list" border stripe> <el-table-column v-for="col in columns" :key="col.prop" v-bind="col" /> <el-table-column v-if="$slots.action" label="操作" fixed="right" width="180"> <template #default="scope"> <slot name="action" :row="scope.row" /> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="fetchData" @current-change="fetchData" /> </div> </template> <script setup lang="ts"> import { ref } from 'vue' interface Props { columns: any[] request: (params: any) => Promise<{ list: any[]; total: number }> initForm?: Record<string, any> } const props = withDefaults(defineProps<Props>(), { initForm: () => ({}) }) const searchForm = ref({ ...props.initForm }) const list = ref<any[]>([]) const loading = ref(false) const page = ref(1) const size = ref(10) const total = ref(0) async function fetchData() { loading.value = true try { const res = await props.request({ page: page.value, size: size.value, ...searchForm.value }) list.value = res.list total.value = res.total } finally { loading.value = false } } function handleSearch() { page.value = 1 fetchData() } function handleReset() { searchForm.value = { ...props.initForm } handleSearch() } defineExpose({ fetchData }) </script>业务页面用起来非常短,这就是抽象的收益。
<template> <ProTable ref="tableRef" :columns="columns" :request="getOrderList"> <template #search="{ form }"> <el-form-item label="订单号"> <el-input v-model="form.orderNo" clearable /> </el-form-item> <el-form-item label="状态"> <el-select v-model="form.status" clearable> <el-option label="待付款" :value="1" /> <el-option label="已完成" :value="2" /> </el-select> </el-form-item> </template> <template #action="{ row }"> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </ProTable> </template>一个列表页从三百行降到八十行,而且新增列表页几乎是复制粘贴改字段,这才是后台开发的正确节奏。
4.3 表单封装与校验
el-form的校验规则我用reactive定义,注意规则里trigger的选择:输入框用blur或者change,下拉选择用change,否则校验时机不对会让用户觉得卡。
const rules = reactive<FormRules>({ name: [ { required: true, message: '请输入名称', trigger: 'blur' }, { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' } ], phone: [ { required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ], type: [{ required: true, message: '请选择类型', trigger: 'change' }] })提交前调formRef.value.validate(),它会返回一个 Promise,校验不通过会 reject,记得try/catch包一下,否则控制台会有未捕获的警告。
编辑弹窗有一个经典问题:打开表单时要把行数据填进去,但直接Object.assign(form, row)会把不该改的字段也带进去,提交时可能污染接口。我的做法是显式声明表单字段,只取需要的。
function openDialog(row?: OrderItem) { dialogVisible.value = true if (row) { Object.keys(form).forEach((k) => { form[k] = row[k] ?? form[k] }) } }4.4 可视化大屏与 ECharts 接入
后台首页经常要放几个图表。ECharts 在 Vue3 里的标准做法是封装一个useEcharts组合式函数,处理初始化、resize、销毁三件事。
// composables/useEcharts.ts import * as echarts from 'echarts' import { onMounted, onBeforeUnmount, shallowRef, watchEffect } from 'vue' export function useEcharts(domRef: Ref<HTMLElement | undefined>, optionRef: Ref<any>) { const chart = shallowRef<echarts.ECharts>() function init() { if (!domRef.value) return chart.value = echarts.init(domRef.value) chart.value.setOption(optionRef.value) } function resize() { chart.value?.resize() } onMounted(() => { init() window.addEventListener('resize', resize) }) onBeforeUnmount(() => { window.removeEventListener('resize', resize) chart.value?.dispose() }) watchEffect(() => { if (chart.value) chart.value.setOption(optionRef.value) }) return { chart, resize } }shallowRef是必须的,用ref包 ECharts 实例会导致 Proxy 递归代理整个图表对象,性能会明显下降,甚至出现渲染异常。
5. 样式、主题与适配
样式这一块是后台系统里最容易积攒技术债的地方。我见过一个项目,两年下来有十几种按钮圆角、七八套主色,原因就是没人管主题变量,全靠页面里写!important覆盖。
5.1 主题色定制与暗黑模式
Element Plus 用的是 CSS 变量体系,改主题色只需要覆盖--el-color-primary一系列变量。最省事的做法是在main.ts之前引入一个自定义样式文件。
// styles/element.scss :root { --el-color-primary: #1677ff; --el-color-primary-light-3: #4a94ff; --el-color-primary-light-5: #7cb2ff; --el-color-primary-light-7: #aed0ff; --el-color-primary-light-8: #c7dfff; --el-color-primary-light-9: #e0eeff; --el-color-primary-dark-2: #125fcc; --el-border-radius-base: 4px; }注意light-3到light-9也要一起改,因为它们被用于 hover、禁用和浅色背景,只改主色会出现按钮正常但 hover 后颜色突兀的问题。这个细节官方文档提得不明显,但实践中非常关键。
暗黑模式用html.dark类切换,Element Plus 已经内置了暗色变量,引入element-plus/theme-chalk/dark/css-vars.css之后,给html加上dark类即可生效。
5.2 pxtorem 对 ECharts 失效的真相
这个问题被问得非常多:用了postcss-pxtorem做移动端适配,页面其他元素都缩放了,只有 ECharts 图表纹丝不动,字特别小或者特别大。
根本原因在于转换链路。postcss-pxtorem只在构建阶段处理 CSS 文件里的 px 值,把font-size: 16px转成font-size: 1rem。而 ECharts 是用 Canvas 绘制的,图表里所有文字、线宽、间距都是运行时通过 JS 参数传给 Canvas 的,根本不经过 CSS 编译。所以 postcss 插件对它完全无能为力。
正确的解决办法有三个方向:
- 方案一:在 ECharts 的配置里手动做换算,把
fontSize写成基于根字号的函数rem(12)。 - 方案二:监听窗口变化,用
chart.resize()之外,重新计算字号并setOption。 - 方案三:不用 pxtorem,改用
vw或者scale缩放整个容器,让 Canvas 跟着容器一起缩放。
我在项目里通常选方案一,写一个工具函数统一处理。
// utils/rem.ts const baseSize = 16 export function rem(px: number) { const root = parseFloat(getComputedStyle(document.documentElement).fontSize) return (px / baseSize) * root } // 使用时 option = { xAxis: { axisLabel: { fontSize: rem(12) } } }提示:ECharts 的
resize只处理尺寸变化,不会重新计算已经设置的字号。如果你用的是响应式布局,窗口变化时字号也应该跟着重算,否则大屏上会出现字很小、图表很大的割裂感。
5.3 Tabs 等组件样式覆写技巧
改 Element Plus 组件样式时,最大的障碍是 scoped 样式打不进去。原因是 Element Plus 的组件内部结构在子组件的 DOM 里,scoped 的>// 推荐:用 :deep 保持隔离 :deep(.el-tabs__item) { font-size: 14px; &.is-active { font-weight: 600; } } // 需要改 tabs 底部条的颜色 :deep(.el-tabs__active-bar) { background-color: var(--el-color-primary); height: 3px; border-radius: 2px; }
写全局样式时一定要加外层限定类名,比如页面根节点加class="order-page",然后.order-page .el-tabs__item { ... }。直接写.el-tabs__item会污染全站,后期排查起来非常痛苦。
6. 打包构建、部署与性能
开发跑得爽不代表上线好用。后台系统上线后最常见的抱怨是首屏加载慢,所以构建配置值得单独讲一节。
6.1 分包策略与体积优化
Vite 默认会把node_modules里的依赖打成一个vendor包,Element Plus 加 ECharts 很容易超过 1MB。建议手动分包。
// vite.config.ts export default defineConfig({ build: { chunkSizeWarningLimit: 1500, rollupOptions: { output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], element: ['element-plus'], echarts: ['echarts'], utils: ['axios', 'dayjs', 'lodash-es'] } } } } })分包之后每个包的体积可控,浏览器也能并行下载。另外,lodash一定要换成lodash-es并按需引入,dayjs要记得安装并配置中文语言包,否则日期会显示成英文。
6.2 部署与 Nginx 配置要点
Vue Router 用的是 history 模式,部署时必须在服务端配置 fallback,否则用户直接访问/order/list会返回 404。Nginx 的配置如下。
server { listen 80; server_name admin.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ~* \.(js|css|png|jpg|svg|woff2)$ { expires 30d; add_header Cache-Control "public, immutable"; } }index.html一定要设置不缓存,否则用户会一直拿到旧版本引用已经删除的 JS 文件,控制台报 404。带 hash 的静态资源可以放心设置长缓存。
6.3 首屏加载优化
几个见效快的做法:
- 路由全部改成懒加载,
() => import()的形式,别在顶部一次性import所有页面。 - ECharts 按需引入,只注册用到的图表类型和组件,比整体引入能省下几百 KB。
- 开启 gzip 或者 brotli 压缩,Nginx 一行配置的事。
- 登录页和主框架优先加载,其他模块等用户点进去再请求。
// ECharts 按需引入 import * as echarts from 'echarts/core' import { BarChart, LineChart } from 'echarts/charts' import { GridComponent, TooltipComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer])7. 高频问题排查速查表
下面这些问题是后台项目里反复出现的,整理成速查表,遇到时直接对号入座。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 刷新后页面白屏或跳 404 | 动态路由未重新注册 | 守卫里判断routesReady,未就绪则重新加载并next({ ...to, replace: true }) |
| 路由跳转成功但内容不渲染 | router-view层级错误或父路由没有对应组件 | 检查嵌套路由的component是否为布局组件,children的path是否用了绝对路径 |
| props 赋值给 data 后不更新 | 直接解构或赋值破坏了响应式 | 用toRefs或computed包装,需要本地可变状态时用watch同步 |
| 打包报 invalid or unexpected token | 文件编码、依赖版本或中文标点 | 检查文件是否为 UTF-8,检查 import 路径是否带了不可见字符 |
| Edge 浏览器右上角按钮异常 | 与自定义标题栏或全屏 API 有关 | 检查是否调用了window.close或全屏接口,普通后台页面不要主动调用这些 API |
| 日期显示英文 | dayjs 未引入中文包 | import 'dayjs/locale/zh-cn'并设置dayjs.locale('zh-cn') |
| 表格列宽抖动 | 未设min-width或固定列宽度冲突 | 给每列设置min-width,固定列单独给固定宽度 |
8. 我在实际项目里踩过的坑
讲几个具体到能对号入座的经历。
第一个是keep-alive不生效。当时排查了很久,最后发现是因为用<script setup>定义的页面组件没有name,而keep-alive的include是按组件名匹配的。解决办法是用defineOptions({ name: 'OrderList' })显式声明。这个坑在 Vue2 时代不存在,因为 Vue2 组件天然有name选项。
第二个是表单重置后 UI 没清空。el-form的resetFields只能重置prop对应的字段,而且要求字段在初始渲染时就存在。如果某个字段是条件渲染出来的,重置就会失效。我后来统一改成手动遍历字段重置,虽然多写几行,但行为可预测。
第三个是接口并发。列表页加载时同时发起了字典查询和列表查询,结果字典还没返回,表格里的状态列已经渲染成了数字。解决办法是把字典请求提到路由守卫或者布局层预加载,页面内只读缓存。这类问题本质上是对数据依赖顺序的忽视,建议在设计接口时就明确哪些是必须前置的。
第四个是 TS 类型膨胀。一开始为了省事到处用any,项目到中期自食其果,改一个字段要全局搜索确认。后来定了个规矩:接口返回类型必须定义,公共组件 props 必须定义,只有确实无法确定的临时处才允许any并加注释。坚持两个月后,重构成本明显下降。
最后一个体会是关于抽象时机。我早期喜欢在项目一开始就把所有东西封装好,结果需求一变化,封装层反而成了枷锁。现在的做法是:同一个逻辑重复写第三遍的时候再抽象。这样抽象出来的东西是经过验证的,接口也更贴合真实需求。后台系统里真正值得提前抽象的只有三样:请求封装、布局框架、权限体系,其余都可以先写后抽。
如果你的项目后续要扩展,我建议优先做两件事:一是把字典和枚举统一管理起来,避免状态值散落在各个页面;二是把公共组件加一个文档页,用vite-plugin-vue-docs之类的方式给同事看用法。后台系统真正的成本不在写代码,而在别人能不能看懂、能不能复用。