简介:这份 Vue3 示例工程面向想快速上手 Vue3 新语法、Element-Plus 组件库与 Mockjs 假数据的前端学习者,也适合需要现成脚手架来扩展新页面的开发者,尤其可用于课程练习或小型业务原型起步。工程已配好 Vite、axios 与 Mockjs,并成功搭建一个基于 Element-Plus table 组件的示例页面,可直接运行并在此基础上继续开发,省去组件引入和环境配置的重复试错。压缩包共 27 个文件,包含 9 个 vue 组件与页面、3 个 json 配置、3 个 js 脚本、3 个 md 说明以及 css、html、图片和图标等辅助文件,整体约 130KB,体量轻便、目录结构简洁,便于按模块查阅和二次修改。目前已有 2140 人学习下载,说明其作为入门参考具有一定认可度。读者可通过现有表格页理解组件注册、接口请求与 Mock 数据拦截的配合方式,也能借鉴 Vue3 组合式写法、入口组织与构建配置,将示例快速改造成自己的练习项目或业务原型。
1. 后端接口还没排期,前端不能干等
产品原型甩过来,后端接口排期在两周后——做过中后台的都不陌生这种场面。这套 vue3-demo 干的事很直接:把 Vue3 框架、Element-Plus 组件库和 mockjs 假数据这三层先拼在一起,路由、菜单、表格分页、弹窗表单、增删改查请求全都摆好,环境依赖已经配好,装完依赖就能跑起来看效果。
它适合两类人。一类是接口没就绪又不想干等的前端,先拿假数据把字段、交互、校验规则跑通,等真接口来了只换请求层;另一类是刚上手组合式 API 的开发者,需要一个结构完整但不臃肿的模板照着改,读一遍就能摸清一次列表查询从页面发出去到数据回填的完整路径。
2. 工程骨架与请求链路:一次列表查询从点击到渲染经过哪些文件
2.1 启动命令与三类依赖的边界
拿到这种"环境已配好"的工程,第一件事不是打开页面点按钮,而是看package.json里 scripts 到底做了什么、依赖分了几类。分清这三类,后面改起来才不会误伤。
# 安装依赖。Vite 5 起的硬要求是 Node 18+,node -v 先看一眼,别在 16 上折腾 npm install # 开发模式,默认 5173,改了 src 下的文件走 HMR npm run dev # 生产构建,产物在 dist/。这一步会暴露 mock 没关干净的问题 npm run build # 本地预览构建产物,验证 history 路由刷新会不会 404 npm run preview四行命令里最容易翻车的是第三行。开发阶段跑得飞快,一构建就发现接口全部 404 或者假数据还活着,原因是 mock 插件的开关没跟command绑在一起。常见的依赖分组是这样:运行时依赖vue、vue-router、pinia、element-plus、axios、mockjs;开发时依赖vite、@vitejs/plugin-vue、vite-plugin-mock、sass;剩下是eslint、unplugin-auto-import这类工具链,不喜欢可以整组删掉,不影响跑。
mockjs放在运行时依赖里其实是个隐患,后面第 5 章会专门说这件事。
2.2 目录分层:api、mock、views、components 各管什么
这类模板看着文件多,实际边界很清晰,一条线是「页面 → api 函数 → request 实例 → 真实后端或 mock」,另一条线是「路由 → layout → views」。
vue3-demo/ ├── mock/ # 假数据定义,只在 dev server 里参与 │ ├── user.js # 一个业务模块一个文件 │ └── _util.js # 统一响应包装、分页切片等公共方法 ├── src/ │ ├── api/ │ │ └── user.js # 只放请求函数,不写业务逻辑 │ ├── components/ # 跨页面复用的哑组件,不感知路由 │ ├── layout/ # 侧边栏 + 顶栏 + <router-view> │ ├── router/ │ │ └── index.js # 路由表 + 全局前置守卫 │ ├── stores/ │ │ └── user.js # Pinia,存 token 和用户信息 │ ├── utils/ │ │ └── request.js # axios 实例 + 拦截器 │ ├── views/ │ │ └── system/user/index.vue │ ├── App.vue │ └── main.js ├── vite.config.js └── package.jsonapi/和mock/是成对出现的:api/user.js里写getUserList(params),mock/user.js里就有一个/api/user/list接着。这种一一对应不是强迫症,是为了接口从假换真时能按文件替换,改一处对一处,不用全局搜 URL 字符串。
components/里我一般只放真正复用的东西,比如封装好的分页器、搜索栏容器、状态标签。页面内部私有的小块直接写在同一个.vue里,别为了"组件化"把一个页面拆成八个文件,回头改个字段要开八个标签页。
2.3 请求链路拆解:axios 实例与拦截器写在哪一层
真正把页面和 mock 连起来的是utils/request.js。这段代码值得逐行看,因为二次开发时改动最频繁的就是它。
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { getToken } from '@/utils/auth' // 一个工程只创建一个实例,baseURL 从环境变量取,方便 dev/prod 切换 const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE || '/api', timeout: 10000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截:把 token 塞进 header,mock 阶段没有 token 也不影响 service.interceptors.request.use( config => { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => Promise.reject(error) ) // 响应拦截:这里约定后端返回 { code, message, data } 三段式 service.interceptors.response.use( response => { const res = response.data // 二进制流(导出 Excel)直接原样返回,别去解 code if (response.config.responseType === 'blob') { return response } if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'error')) } // 关键一步:把 data 剥出来,页面里拿到的直接是业务数据 return res.data }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default service几个参数决定了后面所有页面怎么写。baseURL用VITE_API_BASE兜底成/api,意思是本地开发一律打到/api前缀,mock 文件的 url 也统一加上这个前缀,两边对齐就不会出现"请求发出去了但 mock 没接住"的黑匣子情况。timeout设 10 秒,列表查询足够,导出这类长任务单独在 api 函数里覆盖。响应拦截里剥掉code/message只返回data,所以页面里const list = await getUserList()拿到的是数组而不是整包,这一层约定必须在项目开头定死,中途改会让所有调用点全废。
getToken来自utils/auth,通常是读写localStorage的两个小函数。mock 阶段没有登录接口也无所谓,函数返回空字符串,请求头就不带 Authorization。
3. mockjs 假数据层:让接口在联调前先长出来
3.1 两种接法:插件拦截和 axios 手动拦截
mock 数据落地的常见路子有两条,选哪条取决于你后续怎么维护。
第一条是vite-plugin-mock,在 Vite 的 dev server middleware 里拦截请求,浏览器 Network 里能看见完整的请求和响应,状态码、请求头都是真的,调 axios 拦截器逻辑时不会失真。第二条是纯 mockjs 的Mock.mock(url, method, fn),它劫持的是XMLHttpRequest,请求根本发不出去,Network 面板一片空白,好处是连 Vite 都不用配。这个工程用的前者,因为要验证 token、错误码提示、loading 状态这些东西,Network 里看得到才踏实。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { viteMockServe } from 'vite-plugin-mock' import path from 'path' export default defineConfig(({ command }) => ({ plugins: [ vue(), viteMockServe({ mockPath: 'mock', // 相对于项目根目录的 mock 文件夹 enable: command === 'serve', // 只有 dev 才开,build 时彻底关闭 logger: true, // 控制台打印命中的 mock 接口 watchFiles: true // 改 mock 文件自动重启,省得手动 Ctrl+C }) ], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, server: { port: 5173, // 真接口就绪后打开代理,把 /api 转给后端,mock 和代理二选一 proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true } } } }))enable: command === 'serve'这一行是整个配置里最值钱的一行。很多人图省事写死enable: true,本地开发爽了,构建产物里 mock 代码还在,上线后所有接口返回假数据,排查起来相当难受。mockPath指向根目录的mock而不是src/mock,是为了让 mock 目录在物理上就躺在业务代码外面,删的时候一把rm -rf mock干净利落。logger: true建议开发期一直开着,控制台会打出[vite:mock] /api/user/list这样的日志,请求没命中时一眼就知道是 prefix 错了还是方法写错了。
3.2 一个列表接口的完整写法:分页、筛选、延迟
mock 不是随便返回个数组就完事,得分页、得能筛选、得有一点网络延迟,否则页面上的 loading 动画永远一闪而过,等接真接口才发现加载态写错了。
// mock/user.js import Mock from 'mockjs' const Random = Mock.Random // 用模块级数组保存数据,新增/删除直接操作它,刷新页面就重置 const userList = [] for (let i = 0; i < 57; i++) { userList.push({ id: i + 1, name: Random.cname(), // 随机中文姓名 account: Random.word(5, 10), // 5-10 位随机字母 role: Random.pick(['admin', 'editor', 'viewer']), status: Random.pick([0, 1]), createTime: Random.datetime('yyyy-MM-dd HH:mm:ss') }) } export default [ { url: '/api/user/list', method: 'get', timeout: 300, // 模拟 300ms 延迟,页面 loading 才有意义 response: ({ query }) => { // query 已经被插件解析成对象,不需要 JSON.parse const page = Number(query.page) || 1 const size = Number(query.size) || 10 const keyword = (query.keyword || '').trim() const status = query.status === '' || query.status === undefined ? null : Number(query.status) const filtered = userList.filter(item => { const hitKeyword = keyword ? item.name.includes(keyword) || item.account.includes(keyword) : true const hitStatus = status === null ? true : item.status === status return hitKeyword && hitStatus }) const start = (page - 1) * size return { code: 200, message: 'ok', data: { total: filtered.length, // 总条数给分页组件用 list: filtered.slice(start, start + size) // 当前页切片 } } } } ]response收到的参数是一个对象,里面url、body、query三个字段,GET 走query,POST 走body,且都已经反序列化好。这一点跟直接用Mock.mock不一样,后者拿到的是原始字符串,还得自己JSON.parse(options.body),写起来容易漏。
分页逻辑放在 mock 里做切片,而不是返回全量让前端切,目的是让前端的分页组件拿到真实的total,翻页时真的发一次请求。字段名total和list一旦定下来,页面里el-pagination的:total和表格的:data就绑死了,后面换成真接口时让后端按这个结构返回,前端一行不用改。
Random.cname()、Random.datetime('yyyy-MM-dd HH:mm:ss')、Random.pick()是 mockjs 最常用的三个方法,分别生成中文名、格式化时间、从数组里随机挑一个。想要更多花样可以看Random.ctitle()、Random.county()、Random.image(),但别贪多,字段类型对得上就够。
3.3 增删改查:让假数据真的改得动
只读的 mock 页面点两下就没意思了,真正能验证交互的是增删改查闭环。做法是把数据存在模块作用域的数组里,四个接口都操作它。
// mock/user.js 续,追加到上面的导出数组里 export default [ // ... 上面的 list 接口 { url: '/api/user', method: 'post', timeout: 200, response: ({ body }) => { // body 是插件解析好的对象,直接展开即可 const id = userList.reduce((max, u) => Math.max(max, u.id), 0) + 1 const row = { id, name: body.name, account: body.account, role: body.role || 'viewer', status: body.status ?? 1, createTime: Mock.Random.datetime('yyyy-MM-dd HH:mm:ss') } userList.unshift(row) // 新增放最前面,页面上立刻能看到 return { code: 200, message: '新增成功', data: row } } }, { url: '/api/user/:id', method: 'put', timeout: 200, response: ({ body, query }) => { // 路径参数在 query 里,插件会把 :id 提取成 query.id const id = Number(query.id) const index = userList.findIndex(u => u.id === id) if (index === -1) { return { code: 500, message: '用户不存在', data: null } } userList[index] = { ...userList[index], ...body, id } return { code: 200, message: '保存成功', data: userList[index] } } }, { url: '/api/user/:id', method: 'delete', timeout: 200, response: ({ query }) => { const id = Number(query.id) const index = userList.findIndex(u => u.id === id) if (index > -1) userList.splice(index, 1) return { code: 200, message: '删除成功', data: null } } } ]三个接口共用一个userList,闭包让它在模块加载期间一直活着,所以新增完回列表页能看到那条数据,删除后总数减一,整条链路跟真后端的行为一致。timeout给 200ms,是为了让按钮的 loading 状态有机会显示,也顺便验证一下重复提交有没有做防抖。
有个细节值得注意:userList.unshift(row)把新增数据放到数组头部,这样在第 1 页就能看到刚加的人。如果按push追加到末尾,用户新增完回到第 1 页发现没有,会以为没保存成功,这种误解在 demo 阶段特别容易引发误判。
PUT和DELETE的路径参数写法/api/user/:id依赖插件把:id提取到query里。如果你的插件版本对这块支持不同,改成查询串?id=1也一样能用,别在这上面卡太久。
提示:mock 的内存数组在每次 Vite 重启或改 mock 文件后都会重置,别把调试数据当持久化数据用,需要稳定种子数据就在循环里写死几条。
4. Element-Plus 页面骨架:一个标准 CRUD 页怎么写
4.1 引入方式:全量注册和按需引入的取舍
main.js是所有人第一个看的文件。Element-Plus 有两种引入姿势,全量和按需。
// src/main.js —— 全量引入,简单直接 import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' // 样式必须单独引,忘了就是白板 import zhCn from 'element-plus/es/locale/lang/zh-cn' import App from './App.vue' import router from './router' import { createPinia } from 'pinia' const app = createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus, { locale: zhCn, size: 'default', zIndex: 3000 }) app.mount('#app')locale: zhCn决定了分页器的文案是「共 57 条」还是「Total 57」,这个参数不传,页面上一半中文一半英文,看着像没做完。size: 'default'是全局组件尺寸,做后台管理我一般调到default或small,large在 1080p 屏上一个表格只能放五行。zIndex: 3000是弹窗基础层级,项目里如果有自绘的浮层,把它抬到比 Element 默认的 2000 高,能省掉一堆层级打架的问题。
全量引入的代价是打包体积,element-plus全量大约几百 KB。中小型后台其实无所谓,首屏 gzip 后多出来的部分用户感知不到。真要按需,用unplugin-vue-components配ElementPlusResolver,模板里用到什么自动引什么。但要注意:ElMessage、ElMessageBox、ElLoading这几个是函数式调用,不经过模板编译,自动引入插件收集不到它们的样式,得在main.js里手动补一句import 'element-plus/theme-chalk/el-message.css',否则按钮有样式、消息提示是纯文本。
4.2 列表页:搜索表单、表格、分页三件套
一个 CRUD 页面里 80% 的代码都在列表页。下面这段是能直接抄的骨架,把<script setup>部分拆开看更清楚。
<!-- src/views/system/user/index.vue --> <template> <div class="page"> <!-- 搜索区:inline 表单,回车即查 --> <el-form :inline="true" :model="query" class="search-bar"> <el-form-item label="关键字"> <el-input v-model="query.keyword" placeholder="姓名或账号" clearable @keyup.enter="handleSearch" /> </el-form-item> <el-form-item label="状态"> <el-select v-model="query.status" placeholder="全部" clearable style="width: 120px"> <el-option label="启用" :value="1" /> <el-option label="禁用" :value="0" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleSearch">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form> <!-- 操作区 --> <div class="toolbar"> <el-button type="primary" @click="openDialog('create')">新增</el-button> </div> <!-- 表格:v-loading 绑 loading,空数据用 empty-text --> <el-table v-loading="loading" :data="list" border stripe> <el-table-column prop="id" label="ID" width="70" /> <el-table-column prop="name" label="姓名" min-width="100" /> <el-table-column prop="account" label="账号" min-width="120" /> <el-table-column prop="role" label="角色" width="100" /> <el-table-column label="状态" width="90"> <template #default="{ row }"> <el-tag :type="row.status === 1 ? 'success' : 'info'"> {{ row.status === 1 ? '启用' : '禁用' }} </el-tag> </template> </el-table-column> <el-table-column prop="createTime" label="创建时间" width="170" /> <el-table-column label="操作" width="140" fixed="right"> <template #default="{ row }"> <el-button link type="primary" @click="openDialog('edit', row)">编辑</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button> </template> </el-table-column> </el-table> <!-- 分页:total 来自接口,page/size 双向绑定到 query --> <el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="fetchList" @current-change="fetchList" /> </div> </template>模板部分不需要多解释,值得说的是几个容易写错的地方。v-loading绑的是loading,而loading必须在finally里置回 false,否则接口报错后表格永远转圈。el-table-column的fixed="right"让操作列在横向滚动时钉在右边,字段一多这个属性很救命。min-width和width的区别是前者参与弹性分配、后者固定,混着用能让姓名这种长短不一的列不至于被挤成一条缝。
// 同一文件的 <script setup> import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getUserList, deleteUser } from '@/api/user' const loading = ref(false) const list = ref([]) const total = ref(0) // query 用 reactive,翻页时直接改属性,不需要整体替换 const query = reactive({ page: 1, size: 10, keyword: '', status: '' }) const fetchList = async () => { loading.value = true try { // 拦截器已经剥过一层,这里拿到的就是 { total, list } const data = await getUserList({ ...query }) list.value = data.list total.value = data.total } finally { loading.value = false // 无论成功失败都要关 loading } } const handleSearch = () => { query.page = 1 // 搜索必须回到第一页,否则查出来是空页 fetchList() } const handleReset = () => { query.keyword = '' query.status = '' query.page = 1 fetchList() } const handleDelete = async row => { // 二次确认,catch 掉取消操作,不然会抛未捕获异常 await ElMessageBox.confirm(`确认删除「${row.name}」?`, '提示', { type: 'warning' }) .catch(() => Promise.reject(new Error('cancel'))) .then(async () => { await deleteUser(row.id) ElMessage.success('删除成功') // 删掉当前页最后一条时往前翻一页,避免停在空页 if (list.value.length === 1 && query.page > 1) query.page-- fetchList() }) .catch(() => {}) } onMounted(fetchList)handleSearch里query.page = 1这一行是血泪经验。不重置页码,用户在第 5 页搜索一个只有 3 条结果的关键字,接口返回空列表,页面显示"暂无数据",用户以为没搜到,实际上是页码越界。这类问题在自测时很难碰到,上线后天天有人反馈。
handleDelete里那两个catch看着啰嗦,但不能省。ElMessageBox.confirm在用户点取消时是 reject,不接住控制台就红一片。删除后判断list.value.length === 1再回退页码,是同一个道理的补丁。
4.3 弹窗表单:el-dialog 与 el-form 的校验联动
新增和编辑共用同一个弹窗,靠dialogType区分,这是后台管理里最省代码的写法。
<!-- 接在上面 template 的末尾 --> <el-dialog v-model="dialogVisible" :title="dialogType === 'create' ? '新增用户' : '编辑用户'" width="480px" destroy-on-close @closed="handleDialogClosed" > <el-form ref="formRef" :model="form" :rules="rules" label-width="80px"> <el-form-item label="姓名" prop="name"> <el-input v-model="form.name" placeholder="请输入姓名" /> </el-form-item> <el-form-item label="账号" prop="account"> <el-input v-model="form.account" :disabled="dialogType === 'edit'" /> </el-form-item> <el-form-item label="角色" prop="role"> <el-select v-model="form.role" style="width: 100%"> <el-option label="管理员" value="admin" /> <el-option label="编辑" value="editor" /> <el-option label="访客" value="viewer" /> </el-select> </el-form-item> <el-form-item label="状态" prop="status"> <el-radio-group v-model="form.status"> <el-radio :value="1">启用</el-radio> <el-radio :value="0">禁用</el-radio> </el-radio-group> </el-form-item> </el-form> <template #footer> <el-button @click="dialogVisible = false">取消</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button> </template> </el-dialog>destroy-on-close配合@closed="handleDialogClosed"是必搭的组合。前者让弹窗关闭时销毁内部 DOM,后者在销毁后把form重置回初始值、调formRef.value.clearValidate()清掉红色报错。少了这一步,第二次打开弹窗会看到上次的校验错误还挂着,用户莫名其妙。
// <script setup> 续 import { createUser, updateUser } from '@/api/user' const dialogVisible = ref(false) const dialogType = ref('create') const submitting = ref(false) const formRef = ref(null) const form = reactive({ id: null, name: '', account: '', role: 'viewer', status: 1 }) const rules = { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], account: [ { required: true, message: '请输入账号', trigger: 'blur' }, { min: 5, max: 10, message: '账号长度 5-10 位', trigger: 'blur' } ], role: [{ required: true, message: '请选择角色', trigger: 'change' }] } const openDialog = (type, row) => { dialogType.value = type if (type === 'edit' && row) { Object.assign(form, row) // 浅拷贝字段回填,别直接 form = row,会污染表格数据 } dialogVisible.value = true } const handleSubmit = async () => { // validate 返回 Promise,校验不通过直接 reject const valid = await formRef.value.validate().catch(() => false) if (!valid) return submitting.value = true try { if (dialogType.value === 'create') { await createUser({ ...form }) } else { await updateUser(form.id, { ...form }) } ElMessage.success(dialogType.value === 'create' ? '新增成功' : '保存成功') dialogVisible.value = false fetchList() } finally { submitting.value = false } } const handleDialogClosed = () => { Object.assign(form, { id: null, name: '', account: '', role: 'viewer', status: 1 }) formRef.value?.clearValidate() }Object.assign(form, row)而不是form = row,是为了保持form这个 reactive 对象的引用不变。直接赋值会让表单和表格行指向同一个对象,用户在弹窗里改了名字,还没点确定,表格里那行就变了,这种"未保存先生效"的假象很容易让人误操作。编辑态把账号输入框disabled,也是同样的考虑:账号一般不允许改,前端禁掉比后端报错体验好。
submitting绑到确定按钮的:loading,接口返回前按钮转圈且不可点,顺手解决了重复提交。这个状态用ref而不是reactive,因为它是个布尔值,不需要深层响应。
5. 避坑与排查:Mock 不生效、样式丢失、刷新白屏
5.1 开发阶段的高频问题
现象:Network 里请求 404,控制台也没有 mock 日志。原因多半是三个:mockPath写的相对路径跟实际目录对不上;mock 文件里 url 前缀是/user/list而请求打的是/api/user/list;或者刚改完 mock 文件 dev server 没重启。解决方式是先看启动日志里插件打印的 mock 文件数量和路径,再用浏览器直接访问http://localhost:5173/api/user/list手动触发一次,能返回 JSON 说明 mock 是通的,问题在 baseURL 拼接。前缀这类问题没有任何报错提示,属于纯玄学区域,对齐一次写在 README 里能省掉后面所有人的时间。
现象:新增完数据,列表里没有。原因通常是fetchList没在提交成功后调用,或者调用时query.page还停在后面的页码。还有一种情况是 mock 的userList用push追加到末尾,第一页看不到。解决:提交成功后统一query.page = 1再fetchList(),mock 侧用unshift。
现象:改了 mock 文件,页面数据没变。原因是watchFiles: true没开,或者开了但 HMR 只重启了模块没重启 middleware。最稳妥的做法是改完 mock 手动停一下 dev server 再npm run dev。顺带一提,重启后内存数组会重置,这就是前面说的别把 mock 当持久化库。
5.2 构建和部署阶段才会暴露的问题
现象:npm run build之后,线上接口全返回假数据。原因是viteMockServe的enable写死成true,构建设备里 mock 中间件也被打进去了。解决办法是把enable改成command === 'serve',并且把mockjs从dependencies挪到devDependencies,让打包器在 tree-shaking 阶段自然剔除。如果 mock 文件被src下的代码 import 过,还要额外确认没有残留引用。
现象:按钮没样式,或者ElMessage弹出来是纯文字。原因是element-plus/dist/index.css没引,或者用了按需引入但函数式组件的样式被漏掉。排查方法是在浏览器里选中按钮看类名,有el-button但没有对应样式规则,就是 CSS 没进来。解决:全量引入时确认那行import存在且没被注释;按需引入时手动补el-message.css、el-message-box.css、el-loading.css这三条。
现象:npm run preview里点进详情页再刷新,白屏或 404。原因是createWebHistory模式下,/system/user这种路径在静态服务器上找不到对应文件。本地 preview 不处理 fallback,线上 nginx 也要配try_files $uri $uri/ /index.html。临时方案是改用createWebHashHistory,URL 里多个#,但省掉服务端配置。这个选择在项目初始化时就要定,中途从 history 换 hash 会改掉所有已分享出去的链接。
6. 二次开发:从 demo 换成真接口的改造顺序
这套骨架真正用起来,迟早要把 mock 摘掉。摘的顺序有讲究,顺序错了会改到怀疑人生。
我一般按这张表推进:
| 步骤 | 动作 | 验证方式 |
|---|---|---|
| 1 | 打开server.proxy,把/api代理到后端测试环境 | 浏览器直接访问/api/user/list能看到后端返回 |
| 2 | 关闭viteMockServe的enable,或直接注释插件 | 控制台不再出现[vite:mock]日志 |
| 3 | 逐个文件核对api/里的函数与后端文档 | 用真实参数调一次,看拦截器有没有剥错层级 |
| 4 | 删除mock/目录,卸载mockjs | npm run build产物体积应下降 |
| 5 | 补登录态和 401 跳转 | 清掉 localStorage 后刷新,应跳登录页 |
第三步最容易出岔子。后端返回的字段名跟 mock 约定不一致,比如total写成count、list写成records,这时候不要在页面里改绑定,统一在api/user.js里做一次字段映射,页面保持不动。映射层加在 api 函数里,比散落在各个views里好维护得多。
// src/api/user.js import request from '@/utils/request' export const getUserList = async params => { const data = await request({ url: '/user/list', method: 'get', params }) // 后端字段与前端约定不一致时,在这里收口,页面层不感知 return { list: data.records ?? data.list ?? [], total: data.count ?? data.total ?? 0 } } export const createUser = data => request({ url: '/user', method: 'post', data }) export const updateUser = (id, data) => request({ url: `/user/${id}`, method: 'put', data }) export const deleteUser = id => request({ url: `/user/${id}`, method: 'delete' })这段映射层看着多此一举,但它给了你后悔药:后端改字段名,你只改这四行,页面一行不动。没有这一层,字段一改就得全局搜total,搜出来的结果里还混着分页组件的属性名,改错了构建能过、运行才炸。
还有一个常被忽略的点是登录态接入。demo 里的路由守卫通常是空壳,接入真接口后要补两件事:没 token 时重定向到登录页并带上redirect参数,登录成功后跳回原地址;token 过期时拦截器里统一清 localStorage 再跳转,别在每个页面里各写一遍。router.beforeEach里那个next()的分支写清楚,不然会出现登录页自己也被守卫拦住、无限重定向的死循环。
最后说一个习惯。从那以后我每次拿到这类开箱即用的模板,第一件事都不是跑起来看页面,而是先把mock/整个目录挪到项目外面,跑一遍构建,确认在没有假数据的情况下工程依然能编译通过——先把"假数据在不在"这件事变成可控变量,再去接真接口,才能分清到底是后端的锅还是自己的锅。这套流程强制走一遍大概十分钟,但能省掉后面几小时的互相甩锅。
希望帮到你。
本文还有配套的精品资源,点击获取