
做 Vue 项目最绕不开的就是网络请求和 UI 组件库。不管你是在学 Vue 基础语法还是已经上手开发后台管理系统几乎每个页面都要做同一件事调接口拿数据再通过组件渲染到页面上。这一篇我把这两块内容放在一起讲因为它们在实战中往往是连在一起的——接口返回列表数据你交给表格组件展示表单提交时先校验再调用新增接口请求加载中按钮和表格要显示 loading 状态。处理好网络请求UI 层的体验才能真正立起来选对组件库页面开发效率才能肉眼可见地提升。这篇内容适合刚学完 Vue 3 基础、准备做完整项目的同学也适合已经写过一些业务页面、但习惯“拷贝一段 axios 代码到处改”的开发者。我会把网络请求方案选型、axios 封装思路、主流组件库对比、以及两者如何配合落地讲清楚最后还会整理我在实际项目中踩过的坑。这些内容不是我凭空想的而是从几个上线项目中沉淀下来的通用做法你可以直接拿去用。1. 先想明白网络请求和组件库到底在解决什么问题1.1 前端工程里的“数据管道”和“积木箱”我习惯把前端页面拆成两层来看一层是数据层负责从后端拿数据、传数据另一层是视图层负责把数据变好看、好用的界面。网络请求库就是数据层的关键管道UI 组件库则是视图层的积木箱。很多初学者会把这两样东西当成“两个独立的工具”来学今天装个 axios明天引个 Element Plus两者没有关系。但真实业务里它们是同一条流水线。举个最典型的场景用户点击“查询”按钮组件触发一个事件事件里调用一个 API 函数这个函数内部用 axios 发送网络请求拿到 response 后返回给组件组件再把 loading 关掉、把数据丢给表格。任何一个环节想偷懒不规划后面都会加倍还债。所以我在项目里一般会先定网络请求层再定 UI 组件库。因为组件库只要选型定了怎么引入都有现成文档而网络请求层如果一开始没有封装好后面每个页面都会写重复的 loading 和错误处理改一处接口地址要翻十个文件。1.2 为什么不在这一篇里只讲“怎么用”如果只讲 axios 的 get/post 怎么写、组件库的按钮怎么引用那文档里已经有了没必要我再抄一遍。我更想说的是“怎么组织”请求实例怎么创建、拦截器该放什么逻辑、接口模块怎么划分、组件库的按需引入该怎么配置以及表格、表单这些高频场景下网络请求和组件交互怎么配合。这一篇的路线是先做技术选型再封装基础工具然后接到组件库上最后做一个完整的增删改查演示。这样你学完之后拿这套模式去套自己的业务项目基本不需要做大改动。2. 网络请求方案选型为什么我最终选了 axios2.1 先对比一下现在常见的几种方案Vue 3 项目里常见的网络请求方式有原生 fetch、XMLHttpRequestXHR、axios、以及一些框架自带的请求模块。简单列个对比方案上手成本拦截器请求取消进度事件兼容性建议场景原生 fetch低但写法稍繁琐需要自己包装支持 AbortController需要自己处理流现代浏览器轻量项目或小程序跨端XHR中自己封装麻烦原生支持原生支持好基本已被 axios 替代axios中API 清晰内置内置 CancelToken内置好大多数中后台项目vue-resource低已过时弱弱无一般不推荐选择 axios 主要有三个原因第一它同时支持浏览器和 Node.js 环境我经常在本地写个 Node 脚本模拟接口调试同一套代码能直接复用第二它的拦截器设计非常成熟请求发出前统一加 token、响应回来后统一解包数据这两个需求几乎是后台项目标配第三它的错误处理粒度很合适能拿到 HTTP 状态码、服务端业务码、网络异常这三种信息方便我分开处理。2.2 axios 的“核心能力”在一次登录请求里就能看明白你搜 axios 文档会发现它有很多能力时间超时、并发请求、实例配置、取消请求等。但项目里真正高频使用的是这些baseURL统一接口前缀避免每个接口都写一长串域名timeout请求超时时间防止用户等太久拦截器在请求发送前和响应回来后插入自定义逻辑transformRequest/transformResponse对请求体和响应体做统一处理错误处理区分 error.response、error.request、error.message登录接口是一个很好的示例。用户填完账号密码点击登录我们需要做请求发出前把按钮变成 loading带上必要的请求头如果密码错误后端返回 HTTP 401我们需要提示“账号或密码错误”如果网络断开我们需要提示“网络连接失败”。这些逻辑如果不封装每个页面都要写一遍用 axios 的拦截器就能集中处理。2.3 安装和第一个实例安装很简单npm 或 pnpm 都可以。我更推荐用 pnpm因为现在前端项目依赖很多pnpm 能省不少磁盘空间。npm install axios # 或者 pnpm add axios装好之后别直接在组件里写axios.get(...)我习惯先创建一个实例并设置公共配置。下面是最基础的配置文件先这样用着后面会一步步加东西。// src/utils/request.js import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) export default service这里baseURL我一般读环境变量。开发环境如果配了 Vite 代理就填/api生产环境填真实域名。后面对应地要配置 Vite 的 proxy我留在常见问题里细说。3. 手把手封装一个 axios 实例3.1 拦截器里该放什么逻辑token 注入和统一解包拦截器是整个封装里最有技术含量的部分。我观察很多项目的代码发现要么拦截器什么都不放要么把大量业务逻辑堆在里面。合理的划分是请求拦截器负责“给请求加公共信息”和“防止没必要发出的请求”。最常见的是注入 token// src/utils/request.js service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) { return Promise.reject(error) } )注意这里有个细节config.headers.Authorization并不是所有请求都需要带。有些接口比如登录、刷新 token是公开的我们不需要塞 token。所以我会给 axios 的 config 扩展一个自定义属性比如skipAuth在请求拦截器里判断如果设置了就跳过。这个做法看着简单但能避免很多“公开接口带旧 token 导致鉴权失败”的问题。响应拦截器做的事情更多我一般分成三步第一如果 HTTP 状态码是 2xx就继续往下走第二如果业务码表示成功返回真正的数据第三如果业务码失败统一弹错误提示并 reject。service.interceptors.response.use( (response) { const res response.data // 假设后端返回结构{ code: 0, data: {...}, message: ok } if (res.code 0) { return res.data } // 业务错误比如未登录、权限不足、参数校验失败 if (res.code 401) { // 跳转登录页清空本地登录状态 localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(登录已过期)) } // 其他业务错误码直接用 message 提示 Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message || 请求失败)) }, (error) { // HTTP 层错误超时、404、500、断网等 let message 网络请求失败 if (error.response) { // 服务器返回了状态码比如 500、404 const status error.response.status if (status 404) message 请求地址不存在 else if (status 500) message 服务器内部错误 else message 请求错误(${status}) } else if (error.request) { // 请求已经发出但没有任何响应大概率是网络问题 message 网络连接超时请检查网络 } else { message error.message || 请求失败 } Message.error(message) return Promise.reject(error) } )这里有个常见的坑Message.error这个提示组件如果用 Element Plus 或 Naive UI需要注意它的“重复弹出”问题。下面我会讲到怎么防抖。3.2 把常用的 get、post 方法再包一层响应拦截器我已经统一返回了res.data意味着使用方直接拿到业务数据不用再写res.data.data这种重复代码。但为了让页面调用更舒服我再封装一层请求方法// src/utils/request.js const request { get(url, params, config {}) { return service.get(url, { params, ...config }) }, post(url, data, config {}) { return service.post(url, data, config) }, put(url, data, config {}) { return service.put(url, data, config) }, delete(url, config {}) { return service.delete(url, config) } } export default request你会看到get把params单独提出来了这是 axios 的习惯用法好处是 URL 拼接、参数序列化都由 axios 处理。post的第二个参数直接就是请求体。这样组件里调用就是request.get(/user/list, { page: 1 })非常干净。3.3 把接口按模块拆分而不是集中在单个文件里很多小项目会把所有接口写在一个api.js里刚开始页面少还行页面一多就是灾难。我现在的习惯是一个业务模块对应一个 API 模块比如用户相关放api/user.js订单相关放api/order.js。// src/api/user.js import request from /utils/request export function login(data) { return request.post(/auth/login, data) } export function getUserList(params) { return request.get(/user/list, params) }这样做的好处有三个一是页面引用清晰import { getUserList } from /api/user一眼就知道在哪个模块二是接口地址集中管理后端改路径只改一个文件三是方便做单元测试或者是将来用工具自动生成接口定义。我还会在 API 模块里写注释明确接口传参和返回类型。比如// 获取用户分页列表 // param {Object} params - { pageSize, pageNum, keyword } // returns {Promise{ list: Array, total: number }}Team 合作时别人调用这个函数不用翻后端文档。项目跑久了这个习惯会省下很多沟通成本。4. UI 组件库选型与引入实操4.1 Vue 3 生态下主流组件库到底怎么选Vue 3 发布之后社区已经把组件库洗过一轮牌了。最常见的四个是 Element Plus、Ant Design Vue、Naive UI、TDesign。我直接说我的使用感受组件库风格维护方组件丰富度适合场景注意点Element Plus偏中后台沉稳社区很高企业后台、管理系统默认主题偏大需要自己调紧凑Ant Design Vue借鉴 Ant Design 规范社区高中后台/设计感强的后台样式需要在 vite 里额外编译Naive UI现代简洁TypeScript 友好社区高新项目、偏前端体验体积控制好主题定制灵活TDesign腾讯开源多端团队中高大小程序Web 统一场景部分组件还比较新选型没有绝对“最好”只有“合不合适”。如果你做的是后台管理又需要大量表单表格Element Plus 生态最稳网上案例多遇到问题搜一下就有答案。如果你喜欢更现代的设计想少写样式Naive UI 的体验特别顺滑它的n-data-table和n-form配合很好。我个人的项目经验是公司后台用 Element Plus个人工具型项目用 Naive UI。Ant Design Vue 适合本身设计规范偏向蚂蚁的团队TDesign 适合需要小程序和 Web 统一 UI 的场景。4.2 全量引入和按需引入别只看文档刚接触组件库的同学最喜欢直接全量引入因为“省事”。但全量引入ElementPlus之后打包体积会明显变大。而按需引入也不是简单地把组件写进vue文件就行还需要配套样式处理。先说全量引入主入口main.js里import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)这样确实简单所有组件可直接用。但如果你只是用其中十几个组件打包出来光 Element Plus 的 JS 就有好几百 KB。如果是公司内部后台加载慢几秒还能忍如果是面向 C 端用户的项目体验就很差。4.3 按需引入的正确姿势unplugin 系列插件现在官方推荐的按需引入方式是unplugin-vue-components和unplugin-auto-import。这两个插件会自动解析模板中用到的组件并按需注册同时自动导入组件的样式。以 Vite Element Plus 为例安装插件pnpm add -D unplugin-vue-components unplugin-auto-import然后修改vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue 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插件会自动把el-button和对应的样式引进来不需要手动import { ElButton }。这里有个细节unplugin-auto-import同时能自动导入ElMessage这种函数式组件但需要在原来的配置里加dts: ./auto-imports.d.ts方便 TypeScript 识别否则使用ElMessage时 TS 会报错。还有一个坑我排查过好几次只配了Components没配AutoImport模板组件没问题但使用ElMessage.success这种 API 时样式没有加载出来。原因是它的样式是通过 resolver 按需注入的但你这行直接调用了 API没有走模板解析。解决办法是在AutoImport的 resolver 里一起启用。如果还不生效就手动引入对应样式import element-plus/es/components/message/style/css这种情况虽然不多但遇到了要知道怎么处理。4.4 组件库不是“拷贝代码”先理解组件的数据流很多人把组件库当成代码仓库看到示例代码就复制。其实每个组件都是有“数据流”设计的。比如 Element Plus 的el-table数据是单向的你需要通过:data传入数组点击操作列时通过click拿到当前行的 row。表单组件el-form则是通过model绑定数据对象rules定义验证规则表单校验和提交是分开的两件事。如果没有理解数据流经常会写出“在模板里用 ref 改表格的数据”、或者“form 的 prop 和 model 字段对应不上”这类问题。我在下面第五部分会用网络请求加组件库做一个完整的增删改查例子把这两层的协作逻辑跑通。5. 网络请求和组件库联动一个真实的后台增删改查5.1 先设计接口模块和页面数据结构假设我们要做一个“用户管理”页面后端接口前面已经写过包括分页列表、新增、删除。页面需要的核心数据有三个用户列表数组userList、分页信息pagination、加载状态loading。页面入口先拉一次列表我一般会这样写import { ref, onMounted } from vue import { getUserList, deleteUser } from /api/user import { ElMessage, ElMessageBox } from element-plus const userList ref([]) const loading ref(false) const pagination reactive({ pageNum: 1, pageSize: 10, total: 0 }) async function fetchList() { loading.value true try { const data await getUserList({ pageNum: pagination.pageNum, pageSize: pagination.pageSize }) userList.value data.list || [] pagination.total data.total || 0 } finally { loading.value false } } onMounted(fetchList)注意finally的用法无论接口成功还是失败都要把 loading 关掉。如果你只在成功时关 loading一旦接口报错页面会一直转圈用户只能刷新。5.2 表格 分页 搜索条件的标准玩法模板部分是这样的template div classuser-manage el-card el-form :inlinetrue submit.prevent el-form-item el-input v-modelkeyword placeholder搜索姓名 clearable / /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button /el-form-item /el-form el-table :datauserList v-loadingloading border stripe el-table-column propname label姓名 / el-table-column propphone label手机号 / el-table-column label操作 width160 template #default{ row } el-button typedanger link clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagepagination.pageNum v-model:page-sizepagination.pageSize :totalpagination.total :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next changefetchList / /el-card /div /template这里我刻意用了 Element Plus 的v-loading它比手动写v-ifloading要方便很多而且不会改变页面布局。分页组件上current-page和page-size用了v-model数据变化后自动触发fetchList。需要注意的细节是在搜索场景下点击查询时pageNum要重置为 1否则你在第 5 页搜索结果却显示第 5 页的数据明明只有 2 页。我会这样写function handleSearch() { pagination.pageNum 1 fetchList() }5.3 新增和删除操作表单弹窗与接口怎么衔接新增用户最常见的是点击按钮弹出一个对话框里面放表单。关键点在于表单的校验通过后再去调用新增接口接口成功后关闭弹窗并刷新列表。我习惯把表单弹窗抽成一个子组件数据通过v-model传给父组件。子组件内部负责表单校验、提交按钮 loading 状态、调用新增或编辑接口。!-- UserFormDialog.vue 简化版 -- script setup import { ref, reactive } from vue import { ElMessage } from element-plus import { createUser, updateUser } from /api/user const props defineProps({ modelValue: { type: Boolean, default: false }, row: { type: Object, default: null } }) const emit defineEmits([update:modelValue, success]) const formRef ref(null) const form reactive({ name: , phone: }) const rules { name: [{ required: true, message: 请输入姓名, trigger: blur }], phone: [{ required: true, message: 请输入手机号, trigger: blur }] } const submitLoading ref(false) async function handleSubmit() { if (!formRef.value) return await formRef.value.validate() submitLoading.value true try { if (props.row) { await updateUser(props.row.id, form) } else { await createUser(form) } ElMessage.success(保存成功) emit(update:modelValue, false) emit(success) } finally { submitLoading.value false } } /script这里有两个容易踩的坑。第一await formRef.value.validate()如果校验失败会抛异常所以你不需要额外写valid判断直接try catch即可第二按钮的 loading 状态必须和接口请求关联而不是在点击时手动置 true、在try成功后再置 false。如果接口失败finally也会执行按钮能恢复点击。这是一个非常实用的细节。另外emit(success)之后父组件里监听success重新调用fetchList刷新列表这样就完成了“新增-刷新”的闭环。5.4 组件库配合网络请求的“状态管理”问题这里顺便说一下组件里的loading、error、data这几个状态我建议每个页面自己管理不要一股脑全塞进 Vuex 或 Pinia。因为列表数据往往只是当前页面用存到全局反而容易造成数据不一致。全局状态我只保留用户信息、权限、全局配置等跨页面数据。这算是我被坑过之后的个人经验把该局部的东西局部化代码会清爽很多。6. 常见问题与排查技巧实录6.1 开发环境跨域别去折腾后端本地开发时前端跑在http://localhost:5173后端接口可能跑在http://localhost:8080直接发请求会被浏览器拦截。正确的姿势是让 Vite 开发服务器做代理而不是在后端开 CORS。在vite.config.js里export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这样前端请求/api/user/list开发和部署时 baseURL 都是/api代理到后端时会自动去掉/api前缀如果你的后端接口本来就有/api前缀就不要 rewrite。生产环境通常用 Nginx 做同样的事前端代码不用变。我遇到过同事把baseURL写死成http://localhost:8080结果部署后忘记改线上页面全部请求本地地址这个问题用代理配置就能彻底避免。6.2 请求拦截器加上 token 后登录接口“死循环”这是我见过最典型的坑登录接口也走同一个 axios 实例请求拦截器判断本地没有 token就带了一个过期 token后端返回 401响应拦截器又跳回登录页。表现就是“登录失败后又自动跳登录页”。解决办法我在前面提过给公开接口加一个skipAuth标记export function login(data) { return request.post(/auth/login, data, { skipAuth: true }) }在请求拦截器里判断if (config.skipAuth) { return config } const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} }更进一步如果响应拦截器检测到 401但当前页面已经是登录页就不要强制刷新跳转了做个判断避免反复弹提示。6.3 响应拦截器返回的到底是 response 还是 response.data这个问题新老手都容易搞混。我见过两种写法一种是每个页面都写const res await getUserList(); const list res.data.list;另一种是拦截器里直接return response.data.data页面以为拿到列表结果接口返回undefined。我给的建议是在拦截器里统一把response.data解包并按下图约定不一定非要用code 0以你后端团队定义为准// 成功返回 data // 业务失败抛错 // 网络失败抛错然后页面使用时就只需要关心“数据”和“错误”两种情况不用关心 HTTP 状态码。这个约定最好写进团队协作文档否则每个人写法不同出了 bug 都难排查。6.4 Element Plus 按需引入后Message 样式丢了前面提到过ElMessage、ElNotification这类函数式组件在按需引入时样式可能会被漏掉。症状是提示文字弹出来了但没有任何背景色和边框文字飘在白底上特别丑。解决路径有两种。一种是在组件里手动引入对应样式import element-plus/es/components/message/style/css另一种是确保AutoImport和Components配置都用到ElementPlusResolver然后重启 dev server 清掉缓存。如果还不行检查unplugin版本是否和 Element Plus 版本匹配。多数情况下把unplugin-vue-components更新到最新版就好。6.5 表格数据量大卡顿和渲染慢怎么破一次性把几千条列表数据丢给el-table即使浏览器能撑住滚动和选中也可能卡。常见方案有三个后端分页这最基础把pageSize控制在 10~50 之间。前端虚拟滚动数据量在几百到几千条时可用。Element Plus 的el-table官方没有内置虚拟滚动但社区有方案Naive UI 的n-data-table自带虚拟滚动这块体验更好。懒加载或展开行一些表格支持展开时再请求子表数据适合树形场景。我的建议是优先保证后端分页。如果接口实在一次性返回大量数据且产品要求全部展示那就用支持虚拟滚动的表格组件或者二次封装一个。别用“一次性渲染一万行 DOM”的土办法硬抗。6.6 网络失败时重复弹提示如何避免响应拦截器里写了Message.error业务代码里又catch后Message.error结果一次失败弹两个提示。这个问题的根源是“错误提示职责不清晰”。我的做法是拦截器只负责“可确定的错误提示”比如超时、500、断网。业务代码里的 catch 只处理“业务上的提示”比如“保存失败请稍后重试”。如果拦截器已经提示过业务代码可以静默处理。为了统一我会在拦截器里加一个简单的防抖let messageQueue [] function showMsg(message) { if (messageQueue.includes(message)) return messageQueue.push(message) ElMessage.error(message) setTimeout(() { messageQueue messageQueue.filter((m) m ! message) }, 3000) }这样同一个错误不会在几秒内连续弹出 N 次用户体验好很多。7. 一些额外的实战心得7.1 给接口和状态都贴上“类型标签”如果你用 TypeScript 写 Vue 3一定要给接口请求函数标注返回类型。不用写得太复杂至少把后端返回的结构类型定义出来export interface UserInfo { id: number name: string phone: string } export function getUserList(params: PageParams): PromisePageResultUserInfo { return request.get(/user/list, params) }虽然这一篇的示例代码是 JavaScript 风格但实际项目我强烈建议上 TypeScript。组件库对 TS 的支持都很好接口类型一标注很多 bug 在编译期就能发现不用等运行时调接口再排查。7.2 像写函数一样写 API 模块我写 API 模块时有几条自己的规则一个函数只对应一个接口函数名要能表达“做什么”而不是“路径是什么”默认导出的 request 工具不要随便乱改要加功能就往拦截器里加。这些规则很简单但能保证项目从一两个页面扩展到几十个页面时代码不会乱成一锅粥。7.3 AI 工具能帮你快速生成模板代码现在不少开发者习惯用 AI 辅助写代码前端领域也有不错的插件。我自己会用 Claude Code 这类终端工具来生成表格和表单的组合模板或者帮忙识别 axios 配置里的明显错误。但要注意AI 生成的代码只能当“草稿”你必须自己理解它的完整链路后才能合入业务项目尤其是拦截器里的鉴权逻辑和错误处理这部分一定不能盲信。说到底网络请求和 UI 组件库都是工具真正的价值在于你如何把它们组织成可维护的代码结构。我的体会是花半天时间把 axios 实例封装好把组件库的按需引入配好后面写页面会顺畅很多。如果你刚开始搭建项目建议把这一篇里的思路落地到自己的项目里别上来就开写业务代码。等你跑通了一个最简单的增删改查再往里面加权限、缓存这些复杂功能就会从容很多。