Vue 3 网络请求封装与 Element Plus 组件库选型实战指南
1. 项目到了第10节网络请求这关必须打通学 Vue.js 看到“网络请求”这一节很多人的第一反应是“不就是调个接口嘛”。但真到了实际项目里你会发现网络请求层的设计决定了你后面写页面是舒服还是遭罪。这一节的内容说白了就两件事怎么把数据从后端拿回来、怎么让拿回来的数据在前端界面里体面地展示出来而 UI 组件库就是帮你解决第二个问题的。我见过太多初学者在项目里每个页面身上各自写 axios.get结果后端一改接口返回结构、项目要加统一鉴权、要处理超时和错误提示时改到怀疑人生。网络请求层不是“能调到接口就行”它承担的是鉴权、错误处理、数据解构、统一 loading 这些横切关注点。而 UI 组件库解决的是另一个问题——你不可能每个按钮、每个弹窗都从零写 CSS组件库就是你的“预制件仓库”。这篇内容适合三类人刚把 Vue 基础语法过完、准备撸第一个真实项目的学习者已经在写页面但请求层和组件用法全靠复制粘贴、想系统梳理一下的初中级开发者还有那些想在团队里推行一套规范、但不知道怎么开口的准负责人。我会把网络请求封装的完整思路、UI 组件库的选型落地姿势以及最容易踩的坑一次性讲透尽量让你看完可以直接在自己的项目里改。先说我自己的背景我从 Vue 2 时代用 axios 写业务代码后来维护过中后台管理系统也做过移动端 H5踩过的请求层坑起码能列一页纸。下面这些方案不是教科书式的最佳实践是我在实际项目里验证过、删删改改沉淀下来的一套“够用且不太笨”的做法。2. axios 还是 fetch先想清楚选型的“为什么”2.1 为什么 axios 成了事实标准现在打开任意一个 Vue 项目package.json 里大概率躺着 axios。你可能会问浏览器原生就提供了 fetch为什么大家还要额外引一个库核心原因是 axios 在浏览器和 Node 环境通吃而且它在“请求-响应”这条链路上帮你做了很多脏活累活。比如 axios 默认会把响应数据自动 JSON.parse而 fetch 需要你手动调用 res.json()axios 有超时设置、请求取消、拦截器机制fetch 这些能力要么没有、要么得靠 AbortController 自己拼。真到工程层面拦截器是杀手级功能——所有请求在发出去之前和回来之后都有统一的一道关卡后面我会详细演示这个关卡怎么设计。从团队的协作角度看axios 的错误处理也更顺手。fetch 在网络请求失败时抛异常但 HTTP 状态码是 4xx、5xx 时它并不会 reject你得自己判断 res.ok。axios 则把网络错误和 HTTP 错误都归一到 reject 路径里配合拦截器做统一提示非常简单。对于实际开发来说“所有异常走同一条处理逻辑”能少写数不清的 if else。2.2 fetch 什么时候反而更合适也不是说 fetch 一无是处。如果你的项目请求量很小、也不在乎老浏览器兼容用原生 fetch 能省掉一个依赖。更关键的是现在用 TypeScript 的项目越来越多axios 的类型体操有时候会给你添堵而 fetch 泛型 自定义封装反而能写出更清爽的类型推导。我的个人判断是小项目、纯练习、追求零依赖选 fetch任何需要多环境、多拦截器、统一错误处理的“正经项目”第一选择仍然是 axios。这条经验在团队协作场景下尤为重要——新来的同事不需要花时间理解你们自己写的 fetch 封装直接用 axios 的拦截器几乎是零学习成本因为生态里绝大多数文档和例子都是 axios 的。2.3 我在实际项目中最终选了什么我目前的默认配置是Vue 3 Vite axios element-plus。选 axios 不是因为它比 fetch 高级而是因为社区成熟、文档多、同事招聘来就能上手这种“求稳”的决策在大团队里最重要。不过我会在 axios 之上再做一层薄封装而不是直接到处用 axios.get——这样哪天想换成别的请求库业务代码基本不用动。这种“请求层跟业务层解耦”的做法才是网络请求这节真正的重点。3. 统一封装请求层这层代码值得你在每个项目里都做3.1 目录结构先摆清楚很多项目里请求代码长得五花八门根源在于从第一天开始就没有固定居所。我的习惯是到 src 下建一个 api 目录里面再细分src/ ├── api/ │ ├── modules/ │ │ ├── user.js │ │ ├── order.js │ │ └── ... │ └── request.jsrequest.js 放 axios 实例和拦截器属于“基础设施”modules 底下的文件按业务领域分块每个文件里 export 几个方法页面上 import 这些方法就行。这么分层的好处是业务代码里永远不会直接出现 axios 的身影换请求库只动 request.js。3.2 拦截器里到底该做几件事拦截器是所有网络请求进出的统一关卡我在项目里用代码把它固定成四个职责注入 token、解构业务数据、统一错误处理、控制全局 loading。下面这段代码是我在 Vue 3 项目里常用的模板配合 Composition API 使用非常顺手// src/api/request.js import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求拦截器注入 token、记录日志 service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) // 响应拦截器解构业务数据、统一错误处理 service.interceptors.response.use( (response) { const res response.data // 约定后端返回 { code: 0, data: ..., message: ... } if (res.code 0) { return res.data } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, (error) { if (error.response?.status 401) { // 登录失效跳转 window.location.href /login } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default service这里有个容易踩的坑响应拦截器里如果直接返回 response 而不是 response.data那每个业务页面都得多写一层 .data 的取值。所以我会在拦截器里直接把包装层剥掉让 api 模块里 return 出来的东西直接就是业务数据。前面提到的“解构业务数据”就是指这一步。3.3 业务 API 模块怎么组织才不乱有了统一的 request 实例业务模块的代码会非常简洁比如// src/api/modules/user.js import request from ../request export function fetchUserList(params) { return request({ url: /user/list, method: get, params }) } export function updateUser(data) { return request({ url: /user/update, method: post, data }) }页面里调用的方式也很直接import { fetchUserList } from /api/modules/user const loading ref(false) const list ref([]) const getList async () { loading.value true try { list.value await fetchUserList({ page: 1, size: 20 }) } finally { loading.value false } }注意这里我用finally来关闭 loading而不是在 try 里加载完就关。因为请求失败的话代码会走到 catch如果只在成功路径关 loading失败时 loading 就会一直转圈。这是我自己线上项目里真实遇到过的 bug写出来给你避个雷。3.4 错误处理的分级思路错误处理不能眉毛胡子一把抓。我把网络层的错误分成级别网络不通这种直接全局提示HTTP 401 这种特殊状态码做跳转登录页后端业务错误比如 code 不为 0根据业务码决定是全局提示还是页面自己处理。代码上我用业务码判断 页面可选捕获来处理。比如某些业务场景错误不想弹全局提示api 模块里可以加个选项export function fetchOrderDetail(id, options {}) { return request({ url: /order/${id}, method: get, ...options }) }调用时传入一个自定义的 errorHandler 覆盖默认行为。这个设计的好处是常规错误“默认有人管”特殊错误“允许页面自己管”不用每次请求都重复写错误处理。4. UI 组件库选型别只盯着 star 数4.1 四个主流组件库的真实差异网络请求层打通之后接下来就是界面展示而 UI 组件库的选择直接影响开发效率。现在 Vue 3 生态里能选的主要有这么几个我先把真实差异摆出来组件库风格取向适合场景备注Element Plus中后台、偏正式管理后台、数据平台Vue 3 默认首选生态最全Ant Design Vue企业级、偏重大型中后台系统设计语言统一组件丰富Naive UI轻盈、可定制追求视觉现代感的产品TypeScript 友好暗黑模式方便Vant移动端风格H5 页面、移动端项目移动端组件非常成熟很多人选组件库只看 GitHub star 数这是个大误区。Element Plus 在桌面端几乎无敌但你要是拿去做移动端那真的是用牛刀杀鸡。Vant 在移动端更好用因为它的良构设计天然照顾触屏场景。如果项目两者都有方案通常是后台用 Element PlusH5 用 Vant两边各管各的硬凑一套组件库两头用反而是给自己挖坑。4.2 为什么我个人首选 Element Plus坦白讲Element Plus 不是最漂亮的也不是最轻量的但它赢在两点一是用户基数大你遇到问题时搜到的答案最多二是组件覆盖度高表格、表单、弹窗、日期选择器、上传组件一应俱全基本覆盖中后台 90% 以上的常见需求。尤其是表格和表单的联动场景Element Plus 的成熟度是很多后来者一时追不上的。拿“表格页”举例Element Plus 的 el-table 支持多选、排序、固定列、树形数据配合 el-pagination 分页组件后端列表接口加前端展示落地几乎是一天内能完成的事。换 Naive UI 也不是不行但团队里新人有疑问时Element Plus 的文档和社区案例明显更多。4.3 按需引入 vs 全量引入的横纵对比组件库的引入方式有两种全量引入和按需引入。全量引入的代码是这样import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css app.use(ElementPlus)按需引入在 Vite 里通常配合 unplugin-vue-components 和 unplugin-auto-import 来做// 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 { plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] }全量引入的好处是省心坏处是打包体积大。按需引入需要额外配插件但产物更小首屏加载更快。我一般建议小项目、内部系统全量引入省得出幺蛾子面向用户的产品项目建议按需引入尤其是移动端。按需引入还有一个隐藏坑有些组件的样式是依赖其他组件样式的自动按需引入时不一定能自动带上。比如用 el-date-picker 时偶尔就会发现弹层样式不对这是常见问题我遇到过两三次。遇到这种问题别慌手动把对应样式 import 进来就行。4.4 组件库和业务组件的边界组件库不是万能的你需要有自己的业务组件层。拿用户选择器举例Element Plus 有 el-select但你业务里会反复出现“选用户”这个场景如果每次都写一遍弹窗搜索用户列表的逻辑就太重复了。正确做法是封装一个 UserSelect.vue内部用 el-select 远程搜索对外暴露 v-model。业务组件放 src/components 下命名用驼峰不要和 Element Plus 里的 el- 前缀冲突。我在项目里通常还会建一个 src/components/business 目录专门放这种跟业务强相关的复合组件。这样组件库负责通用能力业务组件负责领域能力互不侵占团队协作也清晰。5. 表格和表单组件库高频场景的实战解法5.1 表格页的固定套路中后台项目里表格页面是最多的。打开 Element Plus 文档el-table 的用法并不复杂但实际工程里表格页总是逃不掉这几个环节请求数据、loading、分页、删除或编辑后的刷新、搜索条件联动。下面是一个相对完整的开发套路template div classtable-page el-form :inlinetrue :modelqueryForm el-form-item label关键词 el-input v-modelqueryForm.keyword placeholder请输入关键词 clearable / /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form el-table v-loadingloading :datatableData el-table-column propid labelID width80 / el-table-column propname label名称 / el-table-column label操作 width150 template #default{ row } el-button sizesmall clickhandleEdit(row)编辑/el-button el-button sizesmall typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryForm.page v-model:page-sizequeryForm.pageSize :totaltotal layouttotal, sizes, prev, pager, next size-changegetList current-changegetList / /div /template script setup import { ref, reactive, onMounted } from vue import { fetchList, deleteItem } from /api/modules/demo import { ElMessage, ElMessageBox } from element-plus const loading ref(false) const tableData ref([]) const total ref(0) const queryForm reactive({ keyword: , page: 1, pageSize: 20 }) const getList async () { loading.value true try { const { list, total: count } await fetchList(queryForm) tableData.value list total.value count } finally { loading.value false } } const handleSearch () { queryForm.page 1 getList() } const handleReset () { queryForm.keyword queryForm.page 1 getList() } const handleDelete (row) { ElMessageBox.confirm(确定删除该条数据吗, 提示, { type: warning }) .then(async () { await deleteItem(row.id) ElMessage.success(删除成功) getList() }) .catch(() {}) } onMounted(getList) /script这里的几个细节你留意一下查询按钮把页码重置为 1避免你在第 5 页搜索后总数不够导致页面偏移删除成功后重新拉数据而不是手动从表格里 splice因为后端数据才最可靠v-loading直接挂在 el-table 上省得在模板里手写 loading 组件。分页还有一个坑是el-pagination 的 current-change 事件会同时被“点击页码”“修改 page-size 后 by size-change”触发两次。我的写法是size-change和current-change都指向 getList但注意 size-change 触发的 getList 里依赖了最新的 pageSize 值所以顺序上要处理好。这听起来麻烦但实际项目里正是这种小细节决定了页面是否好用。5.2 表单校验组件库给的只是工具规则要自己定el-form 的表单校验是组件库的高频亮点。很多人以为配了 rules 就万事大吉结果提交后才发现校验时机不对、自定义校验函数写错、动态校验失效。我的经验有几点model、rules、prop三件套必须配套。rules 里的字段名要和 el-form-item 的 prop 保持一致否则校验完全不生效。触发方式用blur加change组合输入框失焦时校验下拉选择变化时校验避免用户还没操作完就疯狂报错。自定义校验器要返回 callback否则校验永远不通过。编辑场景下要给表单赋值后再校验因为表单初始值是空的一进页面就校验会把所有错误一次性暴露出来体验很差。举一个自定义校验的例子const rules { name: [ { required: true, message: 请输入名称, trigger: blur }, { min: 2, max: 20, message: 长度在 2 到 20 个字符, trigger: blur } ], phone: [ { required: true, message: 请输入手机号, trigger: blur }, { validator: (rule, value, callback) { if (!/^1[3-9]\d{9}$/.test(value)) { callback(new Error(手机号格式不正确)) } else { callback() } }, trigger: blur } ] }这里有一个很多初学者都会遇到的坑在编辑弹窗里如果校验不过但数据已经被修改再次打开弹窗时会看到上一次的校验错误。解决办法是在弹窗每次打开时resetFields()等数据赋值结束后再开校验。表单和弹窗的生命周期配合是实际工作中绕不开的一个熟练度关卡。5.3 日期组件和时间格式的坑我把日期组件单独拎出来说是因为这里我踩过真正的坑。el-date-picker 默认返回的是一个 Date 对象但很多后端接口要的是字符串而且格式五花八门yyyy-MM-dd、yyyy-MM-dd HH:mm:ss、时间戳。如果不做处理提交给后端的数据很可能不是后端要的格式。解决方案有两种用value-formatYYYY-MM-DD HH:mm:ss这种属性直接让组件输出字符串。在提交时统一借助 dayjs 做格式化。我个人倾向于第二种因为不同页面需要的格式可能不一样在提交函数里显式格式化代码可读性更高也方便在同一个表单里同时提交多种格式的日期字段。要是直接依赖组件的 value-format后期后端要求改格式你得到处去找这个属性改起来非常痛苦。6. 网络和 UI 如何协作调试经验与综合案例6.1 请求层和组件库在同一个页面里的协作姿势网络请求和 UI 组件不是孤立的两件事它们在一个页面里协作得是否顺畅直接决定了这个页面的开发体验。以“搜索表单 表格 分页”为例真正顺滑的协作模式是表单数据就是请求参数请求参数变化时自动触发数据刷新或者点击查询按钮触发表格的 loading 和请求过程绑定分页组件当前页变化时请求对应页数据。这套数据流如果设计清楚写起来非常顺。但如果不注意数据流的单向性就会出问题。比如你在表格里用了 row 的某个字段去渲染下拉的选中状态这个选中状态又反过来修改 row 的值很容易出现表格渲染错乱。Vue 的响应式系统不是万能的如果你在表格数据上直接挂额外属性比如 tempFlag这些属性会丢失因为后端返回的列表里根本没有这一项。遇到这种情况我的做法是把额外状态放在一个 Map 里用 row.id 做 key而不是直接改 row。6.2 拿 Network 面板排查线上请求问题写请求层的代码时排查问题靠的是浏览器开发者工具里的 Network 面板。Network 面板里你要关注的不是“请求是否发出去”而是请求的时序、状态码、响应体、请求头。举个例子上线后用户反应表格数据偶尔不对。打开 Network 一看发现有两个同类请求一个先发后回来一个后发先回来渲染时后回来的把先回来的覆盖了——这是经典的“请求竞态”。解决方案是给表格页加一个递增请求序号或使用 AbortController 取消过期请求。在 axios 里可以这样// 竞态处理示例 let requestSeq 0 const getList async (params) { const currentSeq requestSeq const res await fetchList(params) if (currentSeq requestSeq) { tableData.value res.list } }这种思路简单有效重点在于理解竞态出现的原因用户快速切换查询条件时多个请求并发旧响应可能晚到。识别这一类的时序问题才算是真正会调试网络请求。6.3 利用 Mock 提高前后端并行开发效率前端和后端并行开发时接口往往是滞后的。网络请求层搭好后用 Mock 可以保证前端先跑起来。最轻量的方案是 vite-plugin-mock在项目里建几个 mock 文件接口返回你想让后端实现的数据结构。Mock 的价值不只是等后端更多是让你把前端 UI 和交互先验证掉。比如表格页的loading、空状态、异常错误提示这些在后端接口好之前就能测出来。配上 Mock整个开发流程会顺畅很多。我自己常用的套路是先在 Mock 文件里定义好接口返回格式前端按这个格式写页面后端接口联调时改为真实地址如果字段对不上、格式不一致这时改起来成本反而最低因为 Mock 已经确认了前端需要的形态。用 Mock 是在用最低成本换取最大开发自由度这个经验确实帮助过不少刚入行的同事。6.4 周边生态AI 辅助工具对前端请求层开发的改变现在不少团队开始尝试用 AI 辅助编码。比如用 AI 生成一个表单页面网络请求的封装和 UI 组件的使用模式往往是最容易被 AI 正确生成的部分因为这两块的模式非常稳定。但 AI 生成的代码里请求错误的处理经常缺失组件库的按需引入也可能和小报错打架。所以在 AI 生成的代码基础上做人工 review重点看拦截器配置和组件 import 的完整性这是我目前团队实践的思路。7. 我最后想单独强调的一件事网络层要敢重构文章写到这里核心内容基本都覆盖了。如果你只记住一件事我希望是网络请求层和 UI 组件库的选型都不是一锤子买卖项目演进中都值得重构。我见过很多项目的 request.js 从 30 行膨胀到 300 行各种逻辑全往里塞读起来云里雾里。好的实践是保持 request.js 的精简把复杂业务判断下沉到各业务模块里。组件库也一样用久了发现某一个组件满足不了业务果断寻找替代品或者自己封装别让“历史惯性”绑架你的页面质量。最后分享一个我自己的小习惯每次开始一个新项目我都会先把请求层封装和组件库按需引入配置好再让团队成员正式开工。前期多花一个小时后面省下来的是成百上千次的重复沟通和修改。留着这个习惯不管以后 Vue 怎么升级你的前端项目都能开得又快又稳。