ARTICLE DETAIL

资讯详情

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

基于Vue3+Element Plus+axios的自定义文件上传组件实现

基于Vue3+Element Plus+axios的自定义文件上传组件实现 做后台管理系统那阵子我几乎每个项目都要碰一遍文件上传。element-plus 的 el-upload 封装得挺全可一旦碰到“按钮样式要完全定制、进度条要嵌在特定位置、回显要兼容多种文件类型、甚至要支持取消和重试”这类需求默认组件就显得有点僵硬改起来反而比从零写一个还费劲。后来我干脆自己封装了一个自定义上传组件用 element-plus 负责 UI用 axios 负责传输把文件上传与回显、自定义进度条显示这些都攥在自己手里一套组件在三个后台项目里稳定跑了一年多。这篇文章就把这套自定义上传组件的完整实现思路、代码细节、常见坑和排查方法整理出来适合正在用 Vue3 element-plus axios 做中后台项目、又不想被 el-upload 的各种 before-upload 和 http-request 钩子绕晕的同学。跟着走一遍你不仅能写出一个可复用的上传组件还能搞清楚进度条背后的原理以后碰到类似需求也能举一反三。1. 为什么劝你“自建”上传组件1.1 默认组件的“灵活”其实是有边界的el-upload 本身并不弱它的 :before-upload、:http-request、:on-progress 钩子都能处理不少定制需求。但很多项目做到后期真正让人头疼的往往是边界场景触发上传的按钮不想用默认样式而是放在弹窗里、气泡卡片里甚至是一个拖拽区进度条不能放在组件内部而是要显示在文件列表某个固定列或者对话框底部文件需要先经过一次“元数据预处理”比如生成缩略图、计算 MD5再进入上传流程上传接口并不是简单的 multipart/form-data还要附带业务字段、签名参数、自定义 headers回显的数据结构由后端定义上一家返回 data.url下一家返回 data.data.filePath你都得兼容用 el-upload 的时候这些需求基本都靠钩子函数硬塞代码一多钩子之间互相传参、改状态最后变成一团乱麻。自定义组件的核心价值不是“不看别人代码”而是把上传过程拆成一段我们可以完全掌控的状态流选文件 → 校验 → 上传 → 回显每个环节都是一块独立的逻辑出了问题也容易定位。1.2 需求拆解一个上传组件到底要做哪些事动手写之前我习惯先把“文件上传与回显”拆成最小功能清单这样后续写代码时不会东一榔头西一棒子选择文件支持点击和拖拽触发支持 multiple 多选文件校验类型、后缀、大小、数量上限的预检上传过程用 axios 发请求实时获取上传进度进度展示自定义进度条支持上传中、成功、失败三种状态文件回显本地预览图片 base64/objectURL和服务器回显接口返回 URL列表操作删除、重试、取消上传、清空状态管理每个文件都有独立的状态包括 pending、uploading、success、error把这几个点列清楚你的组件骨架基本就出来了后面无非是往这个骨架上填肉。2. 整体设计与方案选型2.1 element-plus 管 UIaxios 管传输分工明确组件设计上我遵循一个原则UI 表现层交给 element-plus网络传输层交给 axios两者通过组件内部的状态对象串起来。为什么进度条要“自定义”其实不是为了炫技而是为了能在任何位置、任何风格下复用。element-plus 的 el-progress 只负责画一根进度条它不关心数据从哪来而 axios 的 onUploadProgress 回调会把上传进度以事件形式吐出来。把这两者一对接一个自定义进度条的核心逻辑就完成了axios 回调 → 更新文件对象的 percent 字段 → el-progress 渲染。中间的每一环都是可插拔的你甚至可以把 el-progress 换成任何你自己写的进度条组件不影响上传逻辑。技术栈选择也比较直白Vue3 script setup现在新项目基本都是组合式 API代码组织更清晰element-plus提供 el-progress、el-icon、el-tag 等基础 UI 组件axios负责上传请求因为它有成熟的 onUploadProgress 回调、取消请求机制和拦截器生态注意如果你用原生 fetch 做上传拿进度是一件很痛苦的事fetch 直到现在都没有原生上传进度事件。所以除非你只想提交文件不关心进度否则我还是建议用 axios。2.2 目录与组件接口设计我的实现目录很简单就是一个 UploadFile.vue 一个 useUpload.ts 组合式函数组件太大时拆出去更好维护。对外暴露的 props 尽量贴合业务需要// UploadFile.vue 的 props 定义示意 const props defineProps({ // 上传接口地址 action: { type: String, required: true }, // 是否多选 multiple: { type: Boolean, default: false }, // 接受的文件类型如 image/png,image/jpeg accept: { type: String, default: }, // 文件大小上限默认 10MB maxSize: { type: Number, default: 10 * 1024 * 1024 }, // 额外附加的表单字段 data: { type: Object, default: () ({}) }, // 自定义请求头 headers: { type: Object, default: () ({}) }, // 文件字段名默认 file name: { type: String, default: file }, // 是否自动上传 autoUpload: { type: Boolean, default: true }, // 外部传初始列表用于编辑回显 modelValue: { type: Array, default: () [] }, })每个文件在内部会被包装成一个 FileItem 对象后续所有逻辑都围绕这个对象展开interface FileItem { uid: string // 唯一标识时间戳随机数 name: string // 文件名 size: number // 文件大小 raw: File // 原始文件对象 status: pending | uploading | success | error percent: number // 上传进度 0-100 url: string // 上传成功后的服务端地址 thumbUrl: string // 本地预览地址objectURL errorMsg: string // 错误信息 controller: AbortController | null // 用于取消上传 }把状态和原始文件塞在同一个对象里最大的好处是模板渲染和逻辑处理都只需要遍历一个数组不会出现“列表状态”和“文件数据”两套数据不同步的问题。3. 核心实现文件选择、校验与状态管理3.1 触发文件选择用原生 input 封装一层自定义组件的自由首先体现在触发方式上。我提供了一个默认的“点击上传”按钮槽位同时支持外部通过插槽传入任意自定义触发节点。template div classcustom-upload div classupload-trigger clicktriggerChoose slot nametrigger el-button typeprimary点击上传/el-button /slot input refinputRef classfile-input typefile :acceptaccept :multiplemultiple changehandleChange / /div /div /template关键点是 input 的样式必须隐藏掉但又要放在触发表单内这样点击按钮能自然触发表单控件。我这里用 opacity: 0 绝对定位 盖住整个点击区域而不是 display: none因为部分浏览器对完全隐藏的文件 input 点击行为会表现不一致。const triggerChoose () { inputRef.value?.click() } const handleChange (e) { const files Array.from(e.target.files || []) addFiles(files) // 每次选择完要清空 value否则重复选择同一个文件不会触发 change e.target.value }这里有一个小而重要的经验选择完文件后一定要把 e.target.value 置空。否则用户先选了 a.png删掉后再选同一个 a.pnginput 的值没变化change 事件根本不会触发组件就像“失灵”了一样。3.2 文件类型与大小校验两条规则都要过文件校验是上传组件最容易漏的一块。很多项目只在后端做校验前端随便选个 .exe 都能点上传体验极差。我封装了 validateFile 函数同时校验后缀和大小const validateFile (file) { // 1. 大小校验 if (file.size props.maxSize) { ElMessage.error(文件 ${file.name} 超过大小限制) return { valid: false, reason: size } } // 2. 后缀名校验用 includes 判断扩展名是否在允许列表里 const ext file.name.split(.).pop()?.toLowerCase() const acceptList props.accept .split(,) .map((item) item.trim().replace(., ).toLowerCase()) .filter(Boolean) if (acceptList.length !acceptList.includes(ext)) { ElMessage.error(文件 ${file.name} 类型不被支持) return { valid: false, reason: type } } return { valid: true, reason: } }后缀名校验这里我直接用到String.prototype.includes和Array.prototype.includes——这也是很多新手纠结的“JS 判断字符串是否包含”的落地场景。建议不要只依赖file.typeMIME 类型因为某些场景下它是空字符串或者可以被用户伪造。后缀名 MIME 双管齐下最稳妥。注意前端校验只是体验优化永远不能在服务端省略校验。浏览器里的文件信息是用户可控的后端才是真正的安全边界。3.3 文件列表状态机设计添加文件时我会为每个文件创建一个独立状态对象const addFiles (files) { files.forEach((file) { const check validateFile(file) if (!check.valid) return const item { uid: ${Date.now()}_${Math.random().toString(36).slice(2, 8)}, name: file.name, size: file.size, raw: file, status: pending, percent: 0, url: , thumbUrl: , errorMsg: , controller: null, } // 图片类文件生成本地预览地址 if (file.type.startsWith(image/)) { item.thumbUrl URL.createObjectURL(file) } fileList.value.push(item) if (props.autoUpload) { uploadItem(item) } }) }有一点需要重点说明URL.createObjectURL(file)生的预览地址在不再需要时必须调用URL.revokeObjectURL(url)释放。否则上传几百张图片后浏览器内存会被 objectURL 占满。这个我放在删除文件和组件卸载时处理。const removeFile (item) { // 如果有上传请求在途先取消 if (item.controller) { item.controller.abort() } // 释放本地预览地址 if (item.thumbUrl) { URL.revokeObjectURL(item.thumbUrl) } const index fileList.value.findIndex((f) f.uid item.uid) if (index ! -1) { fileList.value.splice(index, 1) } }到这里组件的“地基”就完成了能选文件、能校验、能维护列表。接下来是最核心的部分——上传和进度条。4. 核心实现上传逻辑与自定义进度条4.1 axios 上传封装FormData 是唯一主角上传文件本质上就是用 multipart/form-data 发一个 POST 请求。在 axios 里简单且正确的做法是const uploadItem (item) { // 构造 FormData const formData new FormData() formData.append(props.name, item.raw) // 附加业务字段 Object.keys(props.data).forEach((key) { formData.append(key, props.data[key]) }) // 创建 AbortController 用于取消请求 item.controller new AbortController() item.status uploading item.percent 0 axios .post(props.action, formData, { signal: item.controller.signal, onUploadProgress: (e) { // e.loaded 是已上传字节数e.total 是总字节数 if (e.total) { const percent Math.round((e.loaded / e.total) * 100) item.percent Math.min(percent, 99) // 先不提前到 100 } }, headers: { ...props.headers, }, }) .then((res) { item.status success item.percent 100 // 兼容不同后端返回结构 item.url res.data?.url || res.data?.data?.url || ElMessage.success(${item.name} 上传成功) }) .catch((err) { // 主动取消的不算错误 if (axios.isCancel(err) || err.code ERR_CANCELED) { item.status pending item.percent 0 return } item.status error item.errorMsg err.message || 上传失败 ElMessage.error(${item.name} 上传失败${item.errorMsg}) }) }这里有个经验之谈千万不要手动设置Content-Type: multipart/form-data。你只需要把 FormData 实例传给 axios它会自动帮你在请求头里生成带 boundary 的 Content-Type。一旦手动设置boundary 丢失或者错乱后端解析文件直接报错而且这种错误在网络面板里极难察觉。4.2 自定义进度条的渲染逻辑进度条的 UI 我用 el-progress 实现但关键在于和文件状态绑定template div classfile-list div v-foritem in fileList :keyitem.uid classfile-item !-- 文件图标/缩略图 -- div classfile-thumb el-image v-ifitem.thumbUrl :srcitem.thumbUrl :preview-src-list[item.thumbUrl] fitcover / el-icon v-elseDocument //el-icon /div div classfile-info div classfile-name{{ item.name }}/div el-progress v-ifitem.status ! pending :percentageitem.percent :statusprogressStatus(item) :stroke-width6 / /div !-- 操作区 -- div classfile-actions el-button v-ifitem.status uploading link typedanger clickcancelUpload(item) 取消 /el-button el-button v-else-ifitem.status error link typeprimary clickretryUpload(item) 重试 /el-button el-button link typedanger clickremoveFile(item)删除/el-button /div /div /div /template与之配套的进度状态判断函数const progressStatus (item) { if (item.status error) return exception if (item.status success) return success return undefined }这里我把“上传中”的进度控制在 99% 以内而不是直接到 100%是一个刻意的取舍。因为 100% 往往不代表“完成”而是“请求已经发给服务器但还没拿到响应”。如果你把 100% 显示给用户用户会以为已经上传完了实际上服务端可能还在处理、写存储几秒后才返回。把进度停在 99%直到 then 回调里确认成功后再跳到 100%能避免大量的“明明 100% 了但列表里没有回显”的疑惑。4.3 取消上传与重试基于 AbortController新版 axios 已经推荐使用signal: controller.signal来取消请求而不是早年的 CancelToken虽然 CancelToken 还能用但已经是 deprecated 方向。AbortController 的好处是标准 API不依赖 axios还可以在一处控制多个请求。取消上传的实现const cancelUpload (item) { item.controller?.abort() // 状态会在 catch 中统一重置为 pending }重试上传就更简单了重新调用一次 uploadItem 即可const retryUpload (item) { item.errorMsg item.percent 0 uploadItem(item) }这里值得注意用户点击“取消”后catch 分支里需要特殊处理不要把取消当成“上传失败”。用axios.isCancel(err)或者判断err.code ERR_CANCELED都行推荐后者更通用一些。4.4 关于并发与节流的补充思考如果你一次要上传几十个文件默认写法会让所有文件同时发起请求后端很容易被打挂。我通常在组件里加一个简单的并发控制维护一个待上传队列每次只允许 3 个请求同时在途。实现并不复杂滑动窗口即可const concurrency ref(0) const queue ref([]) const enqueueUpload (item) { queue.value.push(item) drainQueue() } const drainQueue () { while (concurrency.value 3 queue.value.length) { const item queue.value.shift() concurrency.value uploadItem(item).finally(() { concurrency.value-- drainQueue() }) } }这个小优化在批量上传场景下作用很明显尤其是后台上传素材包、批量导入文件的功能实测下来响应速度和服务端压力都好了很多。5. 核心实现文件回显的两种模式5.1 本地回显上传之前先见图片本地回显用的是URL.createObjectURL(file)这个在 3.3 节已经出现过。它会在浏览器里生成一段 blob: 开头的临时地址图片能立刻展示不需要等上传返回。这个适合“选完就能看效果”的场景比如头像裁剪、证件照预览。需要注意的是objectURL 只在当前浏览器会话内有效不能保存到数据库objectURL 不会随页面关闭自动清除要手动 revoke否则有内存泄漏大文件比如几百 MB 的视频不建议直接 objectURL 预览整个文件可以只加载元数据或用缩略图方案5.2 服务端回显接口返回 URL 的兼容处理编辑场景下我们需要把后端已存的文件列表回显到组件里。通常后端会返回一个数组元素里包含文件名、URL、大小等字段。我在组件里通过监听modelValue来同步这些数据watch( () props.modelValue, (val) { if (Array.isArray(val)) { fileList.value val.map((item, index) ({ uid: item.url || ${Date.now()}_${index}, name: item.name || , size: item.size || 0, status: success, percent: 100, url: item.url || , thumbUrl: isImageUrl(item.url) ? item.url : , raw: null, controller: null, })) } }, { immediate: true, deep: true } )这里的 isImageUrl 判断我写得很简单const isImageUrl (url) { return /\.(png|jpe?g|gif|webp|svg|bmp)(\?|$)/i.test(url) }之所以用正则去匹配扩展名是因为有些后端返回的 URL 可能带 query 参数比如https://cdn.xxx.com/a.png?auth123直接只看字符串尾部会误判。用正则把扩展名和可能的参数分开最稳。5.3 图片鉴权与 Token 回显的坑很多项目的图片并不是公开可访问的回显时 URL 后面需要带 token 或者需要请求头带鉴权信息。这种场景下直接el-image srcurl是拿不到图片的浏览器没法给 img 标签动态加自定义 Header。我常用的方案有两种方案一后端把鉴权信息放在 URL query 里图片地址如/api/file/123.png?tokenxxx后端解析 query 后返回文件流方案二把图片转 base64 通过接口返回前端直接展示但只适合小图片方案三用 axios 以 blob 方式请求图片然后通过URL.createObjectURL转成 blob 地址方案三的示例代码const loadImageWithAuth async (url) { const res await axios.get(url, { responseType: blob }) return URL.createObjectURL(res.data) }这样el-image :srcblobUrl就能正常展示且请求头里可以带上登录 token。注意这种情况下创建出来的 blob URL 也要适时 revoke。6. 常见问题与实战排查6.1 跨域与请求头导致的上传失败这是上传组件遇到最多的一类问题。现象往往是本地联调正常发到测试环境就报 405 或 CORS 错误。排查步骤我一般是这么走的第一打开网络面板确认是否发出的是 POST 请求、路径是否正确。第二如果出现 OPTIONS 预检请求注意服务器是否允许对应的自定义 headers、方法、Content-Type。上传请求本身是multipart/form-data如果你加了自定义 headers比如 token一定会有 preflight。后端需要把Access-Control-Allow-Headers加上你的自定义头。另外还有一类玄学问题请求发出去了后端也能收到但返回的 JSON 前端拿不到。这通常是响应头里少了Access-Control-Allow-Origin或者没有允许对应的 Content-Type。可以让后端在响应里把Access-Control-Expose-Headers也配置好否则前端能读到的响应头字段会被限制。6.2 进度条不动或者从 0 直接蹦到 100进度条不动的第一个原因大概率是onUploadProgress没有生效。常见情况是你没有把回调放在 axios 请求 config 里而是放到了axios.defaults或者拦截器里结果被覆盖了。第二常见原因是中间有反向代理或网关把文件流缓冲了上传进度只有等到整个文件被代理接收完才一次性上报给前端表现为进度条一动不动然后直接跳 100%。Nginx 环境下可以检查proxy_buffering和client_max_body_size配置。如果进度条从 0 跳到 100 但没有经过中间过程通常是上传文件非常小比如几 KB一瞬间就传完了这是正常的。只有大文件出现这种跳变才需要排查代理缓冲。6.3 进度条卡在 99% 或 100% 不动这个我在 4.2 节提到过根源在于进度事件和请求完成事件是两个不同的事件。onUploadProgress 在浏览器把请求体完全发出后就会触发此时代表“请求已经发出去了”但服务端还在处理要等处理完返回响应then 回调才会执行。你看到的“卡住”其实是服务端处理慢。如果服务端处理时间很长建议在后端设计里把“文件落盘”和“业务处理”拆开先返回上传成功和文件地址再异步处理文件内容比如转码、解析、校验。这也是很多文件服务器的标准做法。前端如果怕用户误解可以在进度 99% 时显示“处理中…”之类的文案。6.4 重复选择同一个文件不触发 change这是 3.1 节提到的必踩坑我在这里再展开一次。input 的 value 并不会因为你删掉列表里的文件而自动变化它只认“用户本次是否选择了新文件”。所以每次 change 处理完必须手动e.target.value 。否则用户第一次选择了 a.txt把 a.txt 从列表删掉后再选一次 a.txt浏览器认为 value 没有变化change 事件不会触发。这个问题在 el-upload 内部其实也处理过但它处理得很隐晦很多直接用原生 input 的同学都会踩进来。6.5 上传中文文件名乱码这个问题要分前端和后端两段看。前端 FormData 里直接 append 的就是中文名浏览器会以 UTF-8 编码。后端经常用 Java 或别的框架默认按 ISO-8859-1 解码结果文件名变成乱码。前端基本无解必须让后端在接收文件名时显式使用 UTF-8 解码。如果你能控制后端这是后端一行配置的事如果控制不了就只能在前端把文件名编码后传给业务字段比如encodeURIComponent(file.name)后端拿到后再解码。6.6 上传大文件浏览器内存溢出或页面卡死这个问题通常不是进度条的问题而是把文件一次性读入了内存。上传大文件建议分片处理前端用 Blob.prototype.slice 把文件切成多块逐片上传后端再合并。断点续传、秒传、分片上传是一个大课题我后来在另一个项目里单独封装过一个分片上传的 hook那个逻辑比本文的基础版复杂很多但设计思路仍然是围绕 FileItem 状态机展开。常见问题速查表表现可能原因处理建议进度条一直 0%onUploadProgress 未生效 / 代理缓冲检查请求 config关闭 Nginx 的 proxy_buffering进度条先到 100% 但接口还在处理请求体已发送服务端未返回前端显示“处理中”后端把文件处理做成异步405 / CORS 报错自定义 headers 触发 preflight后端未放行后端允许对应 headers、方法重复选同一文件无反应input value 未清空change 后手动 e.target.value 中文文件名乱码后端解码不符合 UTF-8后端配置 UTF-8 解码或前端 encodeURIComponent进度条从 0 直接变 100文件太小或代理一次性读完小文件正常大文件检查代理流式配置回显图片裂图URL 带鉴权、img 标签无法带 header用 axios 转 blob 或后端把 token 放 query7. 组件的扩展空间与我的实操体会写这个组件的时候我刻意保持了“基础版可用、扩展版可加”的架构。目前这版组件已经在项目里稳定跑了很久最近我又给它加了两个能力一个是拖拽上传。在触发区域监听 dragover、dragenter、drop 事件阻止默认行为后从e.dataTransfer.files里取文件再塞进 addFiles 方法就行。核心逻辑几乎没动只是入口多了一个。另一个是上传成功后的 fileList 通过emit(update:modelValue, fileList.value)同步给父组件配合 el-form 的校验规则让上传组件能参与到表单提交流程中。这个对中后台项目尤其重要因为很多业务要求“文件没传完不能提交表单”。按我个人经验封装这类组件最忌讳一上来就求大而全。先保证“选文件、传文件、看进度、能回显、能删除”这条主线通畅再根据业务需求慢慢往上面加能力。如果你的项目也经常被上传需求搞到头大不妨按这篇文章的思路自己写一套踩过一轮坑之后你会对文件上传这件事有完全不一样的理解。
返回列表