ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 3项目实战:网络请求封装与UI组件库联动指南

Vue 3项目实战:网络请求封装与UI组件库联动指南 写 Vue 项目的时候我最怕听到的一句话是“页面写得怎么样了”因为页面背后永远是两件事数据从哪来以及数据怎么展示和操作。数据从哪来靠网络请求数据怎么展示和操作靠 UI 组件库。这两件事几乎占据了一个业务系统开发 70% 以上的工作量也决定了项目后期好不好维护。这一篇我就把网络请求和 UI 组件库放在一起讲。不是在卖关子是因为实际操作里它们根本拆不开——你发一个请求拿到列表数据紧接着就要用表格组件渲染用分页组件翻页用弹窗组件做编辑用消息提示组件反馈结果。请求封装得不好组件库用得再溜也会被各种重复代码和错误处理拖死组件库选型不对请求写得多规范页面开发效率也上不去。这篇的内容适合正在做管理后台、中后台系统或者任何以 CRUD 为主的 Vue 3 项目的同学不管是刚入门还是已经写了一段时间都值得对照着梳理一遍自己的项目结构。1. 为什么网络请求和 UI 组件库要放在一起讲1.1 前端页面的两件大事拿数据和画界面先看一个典型场景。你要做一个用户列表页表面上要做的事很清晰打开页面调一个接口拿到用户数组然后把它渲染到表格里再配上分页、搜索、新增、编辑、删除这些操作。但把这件事拆开看你其实在同时处理两条线。第一条线是数据线。你要考虑接口地址放哪里、请求超时时间设多少、请求头要不要带 token、后端返回的错误码怎么统一处理、接口挂了用户看到什么提示、页面离开时请求要不要取消。这些问题任何一个没处理好线上的表现就是白屏、卡死、数据错乱、报错信息看不懂。第二条线是界面线。你要考虑表格列怎么定义、操作按钮放哪里、弹窗表单怎么校验、分页组件在数据量大的时候怎么展示、删除操作要不要二次确认、加载中状态怎么给用户反馈。这些问题任何一个没处理好体验就是页面能用但很难受开发的时候也一个功能写半天。很多人把这两条线分开处理前端架构上把请求层和视图层割裂得非常干净理论上没有错。但在实际业务项目里它们必须在一个频道里协同工作。请求返回的数据结构直接决定了表格列的写法接口的 loading 状态直接驱动着表格的 v-loading后端返回的错误信息直接用在 ElMessage 的提示里。把这两块放在一起梳理你才能在动手写代码之前就把整个链路想清楚。1.2 先选请求方案还是先选组件库有同学问过我项目初始化的时候到底应该先封装请求还是先装组件库我一般建议两条腿同时走但在规划阶段先想请求再想组件库。原因很简单组件库是锦上添花请求层是生死攸关。组件库选错了顶多多写一点样式、多换几个标签组件请求层设计错了后期所有页面都要跟着改牵一发动全身。比如你一开始把请求代码直接写在页面里写了 20 个页面之后想统一加一个 token 刷新逻辑就得改 20 个地方。但如果你一开始就有一个 request 实例改一个文件就够了。反过来组件库的选型会影响你封装请求的方式。比如你选了 Element Plus那响应拦截器里的错误提示可以直接用 ElMessage 弹出如果你没选组件库错误提示可能就要自己写一个 toast。所以实操里我的顺序是先定组件库再定请求封装方案然后动手写第一行业务代码。定组件库这件事不复杂半天就能调研完但它决定了后面很多联动的细节。1.3 这套组合解决的真实业务问题用一个真实项目来说明。我之前做一个设备管理后台功能包括设备列表、新增设备、编辑设备信息、删除设备、设备状态切换。看起来功能不多但涉及的请求接口有十几个每个页面都要处理加载态、提交态、成功提示、失败提示。如果每个页面都单独写一套 axios 调用单独处理 loading单独写错误提示代码量会膨胀得非常快而且后端的接口格式一旦有变化改起来会让人崩溃。把网络请求统一封装、UI 组件库统一接入之后每个页面做的事情就变得很纯粹定义接口参数拿到数据绑定到组件上。加载中、报错、空数据这些状态全部由公共逻辑兜底。这就是这两个主题放在一起的价值不是教你怎么调一个接口、怎么用几个组件而是教你搭一套让所有页面都能复用的基础设施。2. 网络请求方案选型与封装2.1 为什么我选 axios 而不是原生 fetch现在很多人讨论要不要用原生 fetch 替代 axios因为 fetch 是浏览器内置的不用装依赖看起来更轻。但我在实际项目里还是建议用 axios哪怕 Vue 官方文档里给的示例用的是 fetch。fetch 的原生 API 设计得比较底层你需要手动处理很多东西请求头设置、超时控制、取消请求、错误状态判断。尤其是错误处理fetch 只有在网络层面失败的时候才会 rejectHTTP 状态码 400、500 它都认为请求“成功”了你得自己判断 res.ok 再抛异常。axios 则把这些逻辑打包好了拦截器、超时、取消、全局默认配置都是开箱即用。另外axios 在浏览器和 Node 环境里都能跑做服务端渲染或者写接口测试脚本的时候一套代码可以直接复用。我经常在 Node 脚本里引项目里的 request 模块去拉线上数据做验证fetch 做不到这么顺手。用一句话总结我的选型逻辑业务项目追求的是稳定、统一、少踩坑axios 的生态成熟度和社区解决方案丰富度都比裸 fetch 高一个量级。只要项目体积不是小到极致axios 都是更稳的选择。2.2 创建统一的请求实例所谓封装请求第一步永远是创建一个 axios 实例而不是直接用全局的 axios。为什么要实例因为一个项目里可能有多个后端服务比如业务接口一个域名、文件上传一个域名、第三方回调一个域名。每个服务的超时时间、请求头要求都不一样用实例可以隔离配置。创建实例时最关键的三个配置是 baseURL、timeout 和 headers。// src/utils/request.js import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, headers: { Content-Type: application/json;charsetutf-8 } })baseURL 我强烈建议不要硬编码。开发环境走 Vite 的 proxy 转发生产环境走 Nginx 转发所以代码里只需要一个相对路径/api具体转发到哪台服务器由环境配置决定。这样同一套前端代码可以部署到测试、预发、生产多个环境而不用改代码。timeout 设 10 秒是基于大多数业务接口的平均响应时间。如果接口下载文件或者跑报表可能超过 10 秒这种接口可以单独用实例或单独传 config 覆盖超时时间不需要全局放宽。2.3 请求拦截器把 token、公共参数都放在这里请求拦截器做的最主要一件事就是加认证信息。前后端分离项目里后端通常要求请求头带一个 token 字段来识别用户身份。你当然可以在每个接口调用时手动加但那样既容易漏又难维护。放在拦截器里每次请求自动带上干净利落。service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) )除了 token有些项目还会在请求里带一些公共参数比如租户 ID、项目 ID、设备指纹。这些都可以在这里统一处理。还有一个小技巧有些后端的 GET 请求对参数里的中文编码处理有问题你可以在拦截器里对 params 做一层序列化处理或者在 axios 的 paramsSerializer 里统一配置。请求拦截器里容易被忽略的一点是返回值的处理。如果你在拦截器里做了异步操作比如刷新 token、重新登录一定要返回 config 对象否则请求会卡在那里或者丢失配置。这个坑我在早期项目里踩过刷新 token 后请求直接发不出去排查了半天才发现是 config 引用被改了。2.4 响应拦截器后端返回格式不统一怎么办响应拦截器是请求封装的灵魂因为它决定了整个项目里所有接口的“手感”。绝大多数后端接口会返回一个统一的数据结构比如{ code: 0, message: success, data: { ... } }但如果不做拦截处理每个页面拿到 response 之后都要自己判断 code 是不是 0很啰嗦。响应拦截器要做的事情就是把这个判断过程收敛到一个地方。service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) if (res.code 401) { // 跳转登录页 window.location.href /login } return Promise.reject(new Error(res.message)) } return res }, (error) { if (error.response) { const status error.response.status const map { 400: 请求参数错误, 401: 登录状态已过期, 403: 没有权限访问, 404: 请求的资源不存在, 500: 服务器内部错误, 502: 网关错误, 503: 服务不可用 } ElMessage.error(map[status] || 网络异常) } else if (error.code ECONNABORTED) { ElMessage.error(请求超时请稍后重试) } else { ElMessage.error(网络连接失败) } return Promise.reject(error) } )这个拦截器设计里有几个细节值得说。第一业务错误和 HTTP 错误要分开处理。code 非 0 属于业务错误说明接口通了但业务逻辑上没成功status 4xx/5xx 属于 HTTP 错误说明接口本身有问题。两种错误给用户的提示策略不一样不能混在一起。第二401 的处理要谨慎。不要在每个接口报 401 时都弹一个“登录过期”的提示否则用户在一个页面上同时发 3 个请求会连续弹 3 个提示框。我后来改成在拦截器里判断error.config._retry或者加一个全局的过期标记只在第一次 401 时做跳转后续的都直接 reject。第三错误提示用 ElMessage 而不是 alert。这里就体现了为什么前面说组件库的选型会影响请求封装——因为 Element Plus 的 ElMessage 可以直接在非组件环境下调用非常方便。如果你没装组件库这里就得多做一层抽象。3. UI 组件库选型与落地3.1 主流组件库对比Element Plus、Ant Design Vue、Naive UIVue 3 生态里目前用得最多的三套组件库是 Element Plus、Ant Design Vue 和 Naive UI。我给一个基于实际使用的对比表方便你做决定。对比维度Element PlusAnt Design VueNaive UI设计风格清爽、中庸适合中后台偏企业级、视觉较重现代、轻量、灵活Vue 3 支持原生支持社区活跃原生支持跟进较快原生支持TS 友好TypeScript支持支持支持极好按需引入支持可用 unplugin支持可用 unplugin自带 tree-shaking中文文档完善完善较好适合场景快速做管理后台传统企业级应用追求视觉现代感和高度定制生态成熟度最高较高中等我的个人建议是如果你没有特殊偏好默认选 Element Plus。原因很实际——管理后台类的项目需求高度同质化Element Plus 的表格、表单、弹窗、分页组合几乎就是为这些场景量身定做的社区里能找到大量现成的解决方案和踩坑记录面试和协作的通用度也最高。如果你负责的是一个对外展示为主、视觉要求较高的项目Naive UI 的定制能力和主题系统会更顺手。但它的生态相对年轻遇到冷门问题时可参考的资料没那么多。Ant Design Vue 适合团队里以前用 React Ant Design、现在切 Vue 的情况设计语言可以无缝衔接。但它的样式重量比较大如果你的项目对首屏加载体积敏感要慎重。3.2 全量引入还是按需引入安装组件库之后第一步就是决定怎么把它接进项目里。最省事的做法是全量引入三行代码搞定import ElementPlus from element-plus import element-plus/dist/index.css app.use(ElementPlus)全量引入在开发阶段我没有意见省心。但它会把整个组件库的所有组件和样式都打进包里哪怕你只用表格和按钮也会打包进几十个没用到的组件。我用 vite 的构建分析跑过全量引入 Element Plus 会让打包体积多出大概 1MB 左右对于中后台项目来说这不算致命但会拖慢首屏加载。按需引入在正式项目里是更好的选择。所谓按需引入不是说你要手动一个个 import 组件而是借助插件自动完成。先安装两个开发依赖npm install -D unplugin-auto-import unplugin-vue-components然后在 vite.config.js 里配置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({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })这样配置之后你在模板里写el-button插件会自动帮你把 Button 组件和样式引进来你写ElMessage、ElMessageBox这样的 API 调用插件也会自动引入。代码里不用再手动写 import开发体验和全量引入一样顺滑打包体积却小得多。3.3 用 unplugin 自动导入组件和 API这个方案第一次用的时候会有点不习惯因为组件“凭空出现”了模板里直接用el-table触发的原理是编译期的自动解析。刚接触的同学可能担心万一某个组件没有被识别到怎么办实际上 Vite 插件是在编译阶段把模板里的标签名或者代码里的 API 名解析出来再自动补上对应的 import 语句所以编译结果和手动引入是完全等价的。我自己用了很久还没有遇到过因为自动导入导致组件丢失的情况。需要注意的一个点是AutoImport 插件默认也会生成一些类型声明文件。如果你在tsconfig.json里没有配置 includeTypeScript 可能会对模板里的组件类型报错提示无法找到名称。解决方法是把插件生成的auto-imports.d.ts和components.d.ts加入 tsconfig或者在 tsconfig 里设置新的 include 路径。还有一个细节如果项目里同时用了多个组件库比如想混用 Element Plus 和 Naive UIunplugin 的方案也支持多个 resolver 叠加但我建议别这么干。混用组件库会让视觉风格割裂而且样式冲突的问题会非常难排查一个项目只用一个组件库是底线。3.4 样式定制和深覆盖组件库装好之后你迟早会遇到一个需求想改组件默认样式。比如 Element Plus 的默认主题色是蓝色但项目的品牌色是绿色这时候你有两种做法。第一种是官方推荐的方式用 SCSS 变量覆盖。Element Plus 的样式是用 SCSS 写的你可以通过覆盖$primary-color这类变量来生成一套自定义主题。但这种方式配置起来相对繁琐需要引入 SCSS 变量文件、配置额外的 CSS 预处理选项在 Vite 项目里还要额外装一些依赖。第二种是直接对某个组件的 DOM 元素写样式覆盖。但很多组件库的样式类名选择器优先级比较高而且组件内部的 DOM 结构不一定暴露在外面直接用普通选择器覆盖可能不生效。这时就要用 Vue 的深选择器:deep()。style scoped .login-wrapper { :deep(.el-input__wrapper) { background-color: #f5f7fa; border-radius: 8px; } } /style:deep()的作用是把选择器的作用域穿透到子组件内部让我们可以精确地去命中组件库内部的某个元素来改样式。这个技巧我在实际项目里用得非常多几乎是定制组件库样式的必备技能。但也要提醒一下深覆盖越深后期升级组件库版本时样式被冲掉的概率越大能少覆盖就少覆盖重要的视觉定制尽量走主题方案。4. 实战请求模块和组件库配合实现一个完整列表页4.1 目录结构与模块划分理论讲再多不如跑一遍。下面我挑一个最典型的场景——用户列表页从零到一演示请求层和组件库的配合方式。先看目录结构src/ ├── api/ │ └── user.js ├── utils/ │ └── request.js ├── views/ │ └── user/ │ └── index.vue └── main.js这个结构的逻辑是utils/request.js负责通用网络请求能力api/user.js负责用户模块对应的接口定义views/user/index.vue负责页面展示和交互。每个页面只依赖自己模块的 api 文件api 文件只依赖通用的 request 实例依赖关系干净清晰。很多人喜欢把请求直接写在页面里项目小的时候无所谓项目大了之后接口地址散落在各个组件里后端一改接口路径就要全局搜索替换。所以我在项目里强制要求页面组件不允许直接出现 axios 或者在页面里定义 URL所有接口必须走 api 目录下的模块。4.2 封装 request 实例完整代码把 2.2 和 2.4 的代码合并一下再补上一些细节就是一个可以直接放进项目里的通用请求模块。// src/utils/request.js import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } // 关闭重复请求时这里会用到 config 里的自定义标记 return config }, (error) Promise.reject(error) ) service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) if (res.code 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(new Error(res.message)) } return res.data }, (error) { let message 网络异常 if (error.response) { const status error.response.status const statusMap { 400: 请求参数错误, 401: 登录状态已过期, 403: 没有操作权限, 404: 请求的资源不存在, 500: 服务器内部错误, 502: 网关错误, 503: 服务暂不可用 } message statusMap[status] || 请求失败(${status}) } else if (error.code ECONNABORTED) { message 请求超时请稍后重试 } ElMessage.error(message) return Promise.reject(error) } ) export default service注意一个细节我第二层error分支会把error.message暴露给调用方但网络级别的报错信息比较难懂比如Network Error或者timeout of 10000ms exceeded。真实用户看到这些会懵所以这里的message需要做一层翻译给用户看到的是“网络异常”“请求超时”这种能理解的文案。4.3 列表页核心代码表格、分页、loading 联动接下来是页面侧。我用 Vue 3 的script setup语法配合 Element Plus 的表格和分页组件把请求和数据绑到页面上。template div classuser-list page-container div classsearch-bar el-input v-modelqueryParams.keyword placeholder请输入用户名或邮箱 clearable stylewidth: 260px keyup.enterhandleSearch / el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button el-button typesuccess clickhandleCreate新增用户/el-button /div el-table v-loadingloading :datatableData border stripe el-table-column propid labelID width80 / el-table-column propname label姓名 min-width120 / el-table-column propemail label邮箱 min-width180 / el-table-column proprole label角色 width120 / el-table-column propcreatedAt label注册时间 min-width160 / el-table-column label操作 width160 fixedright template #default{ row } el-button link typeprimary clickhandleEdit(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.page v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper classpagination-wrapper size-changefetchList current-changefetchList / /div /template script setup import { ref, reactive, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import { fetchUserList, deleteUser } from /api/user const loading ref(false) const tableData ref([]) const total ref(0) const queryParams reactive({ page: 1, pageSize: 20, keyword: }) async function fetchList() { loading.value true try { const { list, total: count } await fetchUserList(queryParams) tableData.value list total.value count } finally { loading.value false } } function handleSearch() { queryParams.page 1 fetchList() } function handleReset() { queryParams.keyword queryParams.page 1 fetchList() } function handleCreate() { // 实际项目会打开一个表单弹窗 ElMessage.info(跳转到新增页面) } function handleEdit(row) { ElMessage.info(编辑用户${row.name}) } function handleDelete(row) { ElMessageBox.confirm(确定要删除用户「${row.name}」吗, 删除确认, { confirmButtonText: 删除, cancelButtonText: 取消, type: warning }) .then(async () { await deleteUser(row.id) ElMessage.success(删除成功) // 如果当前页只剩一条数据删除后自动回退一页 if (tableData.value.length 1 queryParams.page 1) { queryParams.page - 1 } fetchList() }) .catch(() {}) } onMounted(fetchList) /script对应 api/user.js 的内容很简单import request from /utils/request export function fetchUserList(params) { return request.get(/users, { params }) } export function deleteUser(id) { return request.delete(/users/${id}) }这里有几个值得展开讲的联动细节。第一个是 loading 的控制。用 v-loading 指令绑定一个布尔值请求开始时设为 true请求结束无论成功失败都设为 false所以用了 try/finally 而不是 try/catch。这样保证接口报错了 loading 也能关掉不会出现页面卡在加载中的状态。第二个是分页组件的事件。el-pagination 的 current-change 在页码改变时触发size-change 在每页条数改变时触发两个事件我都指向fetchList。但注意 size-change 触发时页码要重置为 1这里我在 fetchList 内部没有处理实际项目中建议在 size-change 里加一句queryParams.page 1否则你从第 5 页切到每页 50 条时可能拿到的还是第 5 页的数据而后端数据根本不够第 5 页。第三个是删除之后的页码回退。这是很多新手容易忽略的细节当前页只有一条数据时删掉它如果不回退页码下一页的数据不会自动补充界面会显示空白。所以我在删除成功后判断tableData.length 1时把页码减一再重新拉数据。4.4 删除、编辑操作与消息提示的联动再看删除这个操作。它不是一个简单的调接口而是一个完整交互闭环用户点击删除按钮 - 弹出确认框 - 用户确认 - 发请求 - 提示结果 - 刷新列表。Element Plus 提供的 ElMessageBox.confirm 返回一个 Promise用户确认后走 then取消后走 catch。注意 catch 里我什么都没做只是吞掉取消操作的报错否则控制台会打印一个未捕获的 Promise rejection。这个细节如果你不处理测试同学拿浏览器一打开控制台就能看到红彤彤的报错体验很差。消息提示组件 ElMessage 也在整个闭环里扮演了关键角色删除成功用 success 类型给用户一个正向反馈请求失败时响应拦截器里的 error 分支会自动弹出错误提示页面不需要再写一遍重复的错误处理。这就是封装请求层的意义——页面侧只需要关心业务逻辑底层那些重复的错误分支代码已经统一处理了。5. 常见问题与排查技巧实录5.1 跨域报错接口请求失败但 Postman 正常最经典的问题接口在 Postman 里调得好好的放到浏览器里就报跨域错误Network 面板显示请求被 CORS 拦截。原因是前后端分离项目里前端页面跑在http://localhost:5173后端接口在http://localhost:3000浏览器出于安全策略不让前端跨域读取响应。开发环境最省心的解决办法是用 Vite 的代理转发在 vite.config.js 里配置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })配置之后前端请求的地址是/api/usersVite 开发服务器会把这个请求转发到http://localhost:3000/users。浏览器只看到你在请求同源地址跨域问题就绕过去了。生产环境则由 Nginx 做同样的事所以代码里的 baseURL 始终是相对路径。这里最容易搞混的是 rewrite 的用法。如果你的后端接口本身就带/api前缀就不要配 rewrite否则请求路径会被替换掉导致 404。5.2 重复请求导致数据错乱另一个常见问题是用户快速切换分页、疯狂点击搜索按钮导致连续发出多个请求。如果这些请求的返回顺序和发出顺序不一致先发的请求后返回最后页面上呈现的数据可能不是用户当前想看的。最简单的处理方式是加一层“请求锁”let isFetching false async function fetchList() { if (isFetching) return isFetching true loading.value true try { // 发请求 } finally { isFetching false loading.value false } }这种方式适合单个页面内串行请求的场景。如果项目里需要更全面的防重复逻辑可以封装一个基于 AbortController 的方案每次发新请求前把上一次的请求 abort 掉这样旧的请求还没返回就被取消了也不会污染页面数据。我在项目里用的是第三种组合方案列表请求加锁防重复提交类请求在按钮上绑定 loading 状态。按钮的 loading 本身就能挡住二次点击比手动加锁更优雅代码量也更少。5.3 组件库样式不生效或覆盖不了按需引入之后最容易遇到的一个坑是组件库的样式没有跟着进来。症状是页面上元素的 DOM 结构在但看起来没有任何样式光秃秃的。我排查过几次基本都是因为 unplugin 的 resolver 没配置对或者版本之间不兼容。遇到这种问题先检查三件事确认 vite.config.js 里 Components 和 AutoImport 插件确实生效了可以看启动日志里有没有生成 components.d.ts 文件。确认 ElementPlusResolver 被同时传给了 AutoImport 和 Components因为组件标签由 Components 解析ElMessage 这类 JS API 由 AutoImport 解析少配一个都会出问题。如果某个组件在页面上死活没样式在该组件的同级目录临时手动 import 一次对应样式验证是不是按需引入的解析问题再回头调配置。还有一种情况是样式覆盖不生效。组件库内部的 DOM 带 scoped 属性你在自己的 scoped 样式里写.el-input根本选中不到它要用:deep()穿透作用域。前面已经演示过这里再强调一次因为太常见了。5.4 loading 闪烁问题最后说一个体感上的问题。列表请求很快比如 200 毫秒就返回了但 v-loading 遮罩刚显示就被关闭用户会看到“闪一下”的加载效果体验反而比不等更差。解决思路是给 loading 加一个最小展示时间用一个 Promise 控制function withMinDelay(promise, delay 300) { const delayPromise new Promise((resolve) setTimeout(resolve, delay)) return Promise.all([promise, delayPromise]).then(([result]) result) } async function fetchList() { loading.value true try { const data await withMinDelay(fetchUserList(queryParams)) tableData.value data.list } finally { loading.value false } }这样请求哪怕 100 毫秒就返回了loading 也会至少展示 300 毫秒视觉上不会闪现。这个细节看起来很小但在给客户演示系统的时候特别加分。另外一个相关的细节是切换 Tab 或列表刷新时v-loading会覆盖整个表格区域。如果你只想在首次加载时显示全遮罩之后刷新时在原有的表格上提示可以给 v-loading 加一个element-loading-text参数配合element-loading-background设置半透明背景视觉上会柔和很多。写到这里说点实在的回头看网络请求和 UI 组件库这两件事单拆出来讲都不难但实际项目里它们纠缠在一起的地方特别多。我把这一篇的代码整理成了一套可以直接复制到项目里用的基础设施你照着搭一遍再写列表页、表单页、详情页的时候会发现真正要写的业务代码少了一大截。我个人在实际操作中的体会是请求封装的细粒度不要一开始就设计得太复杂。先保证统一的错误提示、统一的 token 注入、统一的返回结构处理这三件事做到位项目就已经成功了一大半。UI 组件库也一样先按团队熟悉度选一套把按需引入和主题配置做好剩下的在业务里慢慢磨合。最后再分享一个小技巧在你把request.js和 api 模块的代码写完、但还没开始写业务页面前先单独打开一个空白页面调一个最简单的登录接口把整个链路——请求发出、token 存储、响应返回、错误拦截——用浏览器 Network 面板完整地走一遍。这十几分钟的时间能帮你省下后面几十个页面的排查时间。
返回列表