先给结论:在这种"UI要融入自研设计体系、进度条要定制动效、文件列表要和表单数据强联动"的场景里,自己封装上传组件,成本其实远低于魔改 el-upload。下面我把完整实现思路、关键代码和这几轮开发里踩过的坑都写清楚,基于 Vue3 组合式 API + element-plus + axios,项目里直接可以抄。
1. 为什么放着现成的 el-upload 不用,偏要自己造轮子
1.1 需求场景还原:弹窗表单里的文件上传与回显
接手过一个后台管理系统,里面有个"新建项目"的弹窗表单,字段不算多,但有个地方比较棘手:除了项目名称、负责人、计划日期这类基础字段,还需要上传一份合作协议附件和几张现场照片。产品经理当时提了几个具体要求:
- 弹窗整体是公司自研设计体系,上传区域要和其他表单控件风格统一,不能一眼看出是组件库的默认样式。
- 上传过程中要展示一条 4px 高的细进度条,带渐变色和动画,不能是圆环,也不能是 element-plus 默认那种又粗又重的进度条。
- 图片传完直接显示缩略图,PDF、Word 这些显示文件名和大小,点击能预览或下载。
- 上传完成拿到的文件 ID 要实时回填给表单数据,提交时和其他字段一起发到后端。
第一反应肯定是用 el-upload,人家毕竟封装了选择文件、拖拽、进度回调、成功失败回调这一整套能力。我一开始也是这么做的,把 el-upload 嵌进弹窗,配上action、headers、on-progress、on-success,看起来挺顺利。但越往后做越难受,具体有几个矛盾点。
1.2 el-upload 的"够用"与"不够用"
先说公道话,el-upload 在标准后台场景里完全够用,配置也不复杂:
| 能力 | el-upload 配置项 | 说明 |
|---|---|---|
| 上传地址 | action | 指定接口路径 |
| 请求头 | headers | 动态 token 可以直接传 |
| 进度回调 | on-progress | 返回 percent,可直接显示 |
| 成功/失败 | on-success/on-error | 处理结果 |
| 受控列表 | v-model:file-list | 组件内部维护 file 数组 |
问题恰恰出在这里:这一整套逻辑是"组件替你想好"的,你在外面看的到,但改不动核心。举例来说,上传区域和文件列表的 DOM 结构是写死的,你想把上传区域和文件列表做成左右布局,或者想在上传中显示一个自定义 loading 小动画,都得写一堆:deep()去覆盖内部样式,改完也不敢保证下个版本升级不坏。
更关键的是状态联动。el-upload 内部对每个文件维护了一套status和response的映射逻辑,但实际业务里,我们需要的往往是"文件上传成功后,立刻把文件 ID 塞进表单的某个数组里",或者"用户删除一个已上传文件时,还要同步调一个删除接口"。这些逻辑在 el-upload 里虽然也能通过on-change、on-remove完成,但你就是得去理解它内部 raw file 和映射后的 file 对象的关系,写出来的代码总有种隔靴搔痒的感觉。
1.3 自定义组件的核心收益
所以这轮开发我直接放弃 el-upload,自己封装了一个组件。做完以后回头看,收益非常明确:
- props 和事件完全由业务定义。对外只暴露
v-model、accept、max-size、multiple、list-type这几个,父组件根本不需要理解内部怎么上传。 - 样式零覆盖成本。进度条、上传区、文件列表都是自己的代码,公司 UI 换风格只需要改组件内部 CSS,不碰其他业务页面。
- 上传状态完全可控。从用户选中文件,到校验、上传中、成功、失败、重试、删除,每个节点都能插入自己的逻辑,这在需求越来越多的后期极其重要。
- 不依赖特定组件库内部实现。底层只用 axios + element-plus 的基础组件(按钮、图标、message 这些),将来即使换了 UI 框架,迁移成本也比到处依赖 el-upload 低很多。
2. 上传链路拆解:从选择文件到服务端接收
2.1 隐藏的 input[type=file]:用 ref 触发文件选择器
自定义上传组件第一步,就是把"用户点击上传区域"和"弹出文件选择器"打通。我的做法是在组件里放一个隐藏的<input type="file">,用模板 ref 指向它,上传区域绑定 click 事件,手动调用 input 的 click()。
<script setup> import { ref } from 'vue'; const fileInput = ref(null); const fileList = ref([]); function triggerSelect() { fileInput.value.click(); } function handleFileChange(event) { const files = Array.from(event.target.files || []); // 后续处理:校验、加进列表、触发上传 addFiles(files); // 关键:清空 input 的 value,否则重复选择同一文件不会触发 change event.target.value = ''; } </script> <template> <div class="upload-wrap"> <div class="upload-area" @click="triggerSelect"> <div class="upload-icon">+</div> <p class="upload-tip">点击或拖拽文件到此处</p> <p class="upload-subtip">支持 jpg/png/pdf,单个不超过 10MB</p> </div> <input ref="fileInput" type="file" :accept="accept" :multiple="multiple" class="hidden-input" @change="handleFileChange" /> </div> </template>有几个细节容易踩:
accept只是软过滤。用户仍然可以在选择器里切换到"所有文件",所以真正严格的类型校验必须在代码里做,不能依赖浏览器。- 清空 input.value 这行代码不能省。如果不清空,用户第一次选了 a.pdf,删除以后想再选一次 a.pdf,change 事件不会触发,页面毫无反应。
- 如果要做拖拽上传,在容器上监听
dragover(必须 preventDefault,否则浏览器默认行为是打开文件)和drop,在 drop 里取event.dataTransfer.files,后续逻辑和 change 处理完全复用即可。
2.2 FormData 构造与 axios 请求:Content-Type 的隐形坑
拿到 File 对象后,上传的核心就是构造FormData,再用 axios 发multipart/form-data请求。构造方式如下:
function buildFormData(rawFile, bizType) { const formData = new FormData(); // 字段顺序建议:先 append 文本字段,再 append 文件 formData.append('bizType', bizType); formData.append('file', rawFile); // 如果后端还需要额外信息,比如文件原名、所属业务ID,也在这里 append formData.append('fileName', rawFile.name); return formData; }很多新手在这里会犯一个错:手动设置headers['Content-Type'] = 'multipart/form-data'。实际上,axios 在检测到数据是 FormData 实例时,会自己加上Content-Type: multipart/form-data; boundary=...。如果你手动写死 Content-Type,反而会丢失浏览器自动生成的 boundary,后端解析 multipart 时直接失败,报错看起来像"文件字段为空"或者"请求格式错误"。
发请求的代码:
import axiosInstance from '@/utils/request'; async function uploadFile(rawFile, businessType) { const formData = buildFormData(rawFile, businessType); const response = await axiosInstance.post('/api/v1/file/upload', formData, { timeout: 120000, onUploadProgress: (progressEvent) => { // 计算百分比,后面章节细说 }, }); return response; }2.3 请求拦截器与统一响应结构约定
实际项目里,上传组件的请求要走统一的 axios 实例,因为拦截器里已经处理了 token、超时、错误提示这些通用逻辑。
我这边封装的 axios 实例会在请求拦截器里从 store 里取 token,加到 header 中。这样上传组件内部不用关心认证问题,只管传文件和接结果。
响应结构建议和后端提前约定好,长这样最省事:
{ "code": 0, "message": "success", "data": { "fileId": "uuid-xxxx", "url": "https://cdn.example.com/files/xxxx.pdf", "name": "合作协议.pdf", "size": 204800, "suffix": "pdf" } }约定方案是最省心的:code === 0表示成功,否则把message展示给用户。data里至少要包含fileId和url,一个用于回填业务数据,一个用于回显。
如果后端现在不是这个结构,就套一层适配函数把它转成这个结构。前端组件只认这一个结构,后续后端改字段,只改适配函数,不动组件。
3. 自定义进度条:onUploadProgress 背后的原理与实现
3.1 进度数据从哪来:XMLHttpRequest 的 upload 事件
axios 的onUploadProgress之所以能用,是因为浏览器端 XMLHttpRequest 提供了upload.onprogress事件。axios 内部用 xhr 发送请求时,会把传入的onUploadProgress回调透传绑到 upload 事件上。所以这个回调的触发频率,就是浏览器上传数据的实际频率,大文件上传时可能几十毫秒触发一次。
progressEvent 事件对象里几个关键属性:
| 属性 | 含义 | 注意事项 |
|---|---|---|
loaded | 已上传字节数 | 单位是字节 |
total | 总字节数 | 可能为 0,要兜底 |
progress | loaded / total | 某些场景下可能超过 1,需要自己夹紧 |
计算百分比时,我的防御写法是这样:
function calcPercent(progressEvent) { if (!progressEvent.total) return 0; return Math.min(100, Math.round((progressEvent.loaded / progressEvent.total) * 100)); }total为 0 的情况通常出现在跨域请求未配置 CORS、或者请求体是流式数据时,强行计算会得到 NaN,进而让 UI 上显示"NaN%",必须兜底为 0。
3.2 纯 div 进度条:最简单的自定义方案
自定义进度条我直接用纯 div + 动态宽度,不用 el-progress。原因很简单:需求要求 4px 高、圆角、渐变背景,el-progress 的 line 类型自带厚度和百分比文字,要覆盖成细条还得多写样式,不如直接用个 div 干净。
模板结构:
<div class="progress-bar"> <div class="progress-bar__inner" :style="{ width: percent + '%' }" ></div> </div>核心样式:
.progress-bar { height: 4px; border-radius: 2px; background: #e9edf2; overflow: hidden; } .progress-bar__inner { height: 100%; border-radius: 2px; background: linear-gradient(90deg, #4a7cf7, #6aa5ff); transition: width 0.2s ease; }transition: width 0.2s ease这行一定要加。因为没有过渡动画时,onUploadProgress 回调的高频推进会变成一档一档的生硬跳动,视觉上非常卡;加了 0.2s 缓动后,进度条像被"润"了一下,看起来平滑很多。但注意不要超过 0.3s,否则进度条会明显落后真实进度,看起来不跟手。
3.3 支持取消上传:AbortController 的接入
上传任务经常会遇到用户点错了、想取消的场景。老版本 axios 用 CancelToken,但新版本已经标记 deprecated,标准做法是AbortController。
const controller = new AbortController(); async function startUpload(fileItem) { try { const response = await axiosInstance.post('/api/v1/file/upload', buildFormData(fileItem.raw), { signal: controller.signal, onUploadProgress: (e) => { fileItem.percent = calcPercent(e); }, }); // 处理成功 } catch (error) { if (axios.isCancel(error) || error.name === 'AbortError') { // 用户主动取消,不弹错误提示,把状态置为"已取消"或者从列表移除 fileItem.status = 'canceled'; } else { // 真正的网络错误 / 业务错误 fileItem.status = 'error'; } } }这里有个细节:取消请求后,axios 抛出的错误在浏览器里通常叫AbortError,在 Node 端可能会不同,所以判断条件写error.name === 'AbortError'兼容性更好,或者直接用axios.isCancel(error)。
3.4 进度条伪 100% 的问题:服务端处理时间的补偿
实际开发里我遇到一个很影响体验的问题:所有文件上传,进度条都会卡在 99% 或者直接跳到 100%,然后要等一两秒才真正收到响应。原因是onUploadProgress到达 100% 表示的是"浏览器已经把数据全部发出去了",但服务端接收、存储、处理、返回响应还需要时间。这段时间里进度条已经到顶却还没拿到结果,用户就会觉得卡住了。
处理方案我试过两种:
- 乐观式:进度条显示到 95% 封顶,等响应真正返回后再跳到 100%。对大多数场景足够,且永远不会出现"100% 但还在转圈"的尴尬。
- 悲观式:干脆在响应返回前一直显示"上传中"文案,配合一个 indeterminate 动画。适合超大文件、服务端处理耗时很长的场景。
我最终选的是乐观式。实现上只需要在 onUploadProgress 里做一个映射:
function displayPercent(rawPercent) { // 把真实的 0-100 映射到 0-95 return Math.min(95, Math.round(rawPercent * 0.95)); }上传成功后:
fileItem.percent = 100; fileItem.status = 'success';这个 95% 封顶有个附带好处:它天然规避了"进度条刚到 100%,用户就误以为可以关页面"的误操作,虽然只是一个小小的感知优化,但在后台系统里很值得做。
4. 文件回显:列表、预览与数据流设计
4.1 组件 v-model 的数据结构设计
自定义上传组件最大的价值,是让文件列表数据结构完全由业务定义。我最终定的文件对象结构:
// 文件项 { uid: '本地唯一标识,Date.now() + 随机数生成', name: '文件原始名称', size: 文件大小,字节为单位, status: 'ready' | 'uploading' | 'success' | 'error' | 'canceled', percent: 0, raw: <File 对象,仅本地选择后存在>, fileId: '上传成功后服务端返回的文件ID', url: '上传成功后的访问地址,回显用', response: '服务端完整响应,必要时保留调试用', }v-model 绑定的就是fileItem数组。组件对外只暴露两个事件:
update:modelValue:同步数组变化change:通知父组件文件列表有变动
change 事件里我习惯直接把最业务化的数据传出去:fileList.value.filter(f => f.status === 'success').map(f => f.fileId)。父组件拿到这个数组,直接塞进表单提交即可,不用再自己遍历。
4.2 图片预览与文件下载的区分处理
回显时根据不同文件类型走不同分支:
- 图片(jpg/png/gif/webp/svg):渲染 img 标签,src 用 url。
- PDF:新开 tab 预览,但如果 url 是下载地址,需要另外接一个预览接口。
- 其他类型:展示文件图标 + 文件名 + 大小,点击触发下载。
判断逻辑我直接写个函数:
const IMAGE_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; function isImage(fileName) { const ext = fileName.split('.').pop().toLowerCase(); return IMAGE_EXT.includes(ext); } function getFileSuffix(name) { return name.lastIndexOf('.') > -1 ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''; }图片预览这块有个值得注意的点:如果是本地刚选中的文件,还没有服务器 url,可以先URL.createObjectURL(raw)生成一个本地 blob 地址用于即时预览。上传成功后再把 img 的 src 替换成服务器返回的真实 url。注意替换后要手动URL.revokeObjectURL(blobUrl),否则大图片多传几张,浏览器内存会明显上涨。
4.3 鉴权 URL 与缩略图的处理
编辑页面回显已上传文件时,经常遇到一个问题:文件列表是后端接口返回的,每个文件的 url 如果是 OSS 带签名地址,签名通常有时效,比如 30 分钟。用户打开编辑页,填了半小时表单准备提交,发现之前回显的图片已经因为签名过期而裂图了。
处理办法是在组件里约定:回显时不直接用死 url,而是提供resolveFileUrl这个可选函数,由父组件传入。组件渲染时调用它,根据 fileId 动态拉取临时 url。虽然多了一次请求,但彻底解决了签名过期问题。
缩略图方面,如果服务端用七牛或阿里云 OSS,可以在 url 后面拼图片处理参数,比如?imageView2/1/w/200/h/200,让 CDN 直接返回缩略图,减少首屏流量。如果后端没有图片处理能力,前端用 canvas 压缩缩略图是一套方案,但维护成本不低,对于后台系统来说不太值得做,直接用原图加loading="lazy"更简单。
5. 实战中踩过的坑与边界处理
5.1 同一次请求里的文件与其他字段混传的顺序问题
有些场景,文件不是单独上传,而是和其他表单字段一起提交到同一个接口。multipart 格式本身支持文本字段和文件共存,但我在实际对接时发现,某些后端框架对"先文件后字段"的解析会有问题,字段会读不到。稳妥起见,FormData构造顺序是:先 append 普通字段,再 append 文件。这不算什么硬性规范,但照做能省掉很多无谓的排查。
5.2 重复选择同一文件无法触发 change 事件
前面提过一次,再强调一遍:input的 value 在每次处理完 change 之后必须重置。如果不重置,用户选 a.pdf 传完后发现传错,再选一次 a.pdf,组件纹丝不动,因为浏览器认为 input 的 value 没有变化,不触发 change。解决办法就是event.target.value = ''。如果是拖拽上传,则没有这个问题,因为 drop 事件不依赖 input 的 value。
5.3 大文件上传与并发限制
用户一次选 20 张 5MB 的图,如果全部同时并发上传,公司网关或者后端服务大概率会返回 429 或者直接拒绝连接。我这里加了一层简单的并发池控制,把并发数量限制在 3 个以内:
async function runWithConcurrency(tasks, limit = 3) { const pool = new Set(); for (const task of tasks) { if (pool.size >= limit) { await Promise.race([...pool]); } const promise = task().finally(() => pool.delete(promise)); pool.add(promise); } await Promise.all(pool); }用法就是把每个文件的最终上传 Promise 放进去。限制limit = 3,既不会太慢,也不会打到服务端极致。实测下来,普通后台系统 3 到 4 个并发是比较舒服的数值。
5.4 状态同步的过度设计教训
最后说一个我在开发后期主动做减法的经验。最初封装组件时,我把状态设计得很"完善",每个文件除了基础字段,还有上传队列、全局进度、重试次数、上传失败后的补偿任务等等,还把文件队列放进了全局 store 管理,用 action 分发上传任务。结果项目上线前代码 review,发现自己把简单问题复杂化了。
文件上传本质上是组件内部的事,父组件只关心"最终结果"。全局状态管理完全没有必要,反而让组件无法独立复用,测试也不好写。最终我重构为组件内部维护一个ref([])数组,所有状态都在组件内闭环,删除 store 依赖后,代码量减少三分之一,逻辑也更清晰。
另一个原则:emit 出去的数据不要传太深的引用。父组件需要 fileId 列表就传字符串数组,需要完整列表就传浅拷贝,不要把我组件内部维护的响应式对象直接丢出去,否则父组件不小心修改了某一层属性,排查起来会很痛苦。
个人在实际操作中的体会是,自定义上传组件这件事,真正的工作量不在"把文件发出去",而在于把这条链路的每个环节都想透:文件从哪来、校验规则是什么、进度怎么算、失败怎么办、成功后数据怎么回流、回显时 url 失效怎么兜底。把这些问题都收敛到组件内部,上层业务才会清爽。分享一个小技巧,封装时一定要加一个 debug 开关,用 console 输出每个文件节点在"选择、校验、上传中、成功、失败"这几个状态的流转日志。线上出问题时,让用户开启 debug 模式,把控制台发给你,排查效率会翻好几倍,这算是我踩过最多坑之后养成的一个习惯。