ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Element UI Upload + COS SDK实现文件直传与临时密钥实践

Element UI Upload + COS SDK实现文件直传与临时密钥实践 做后台管理系统做到第三年我发现自己最怵的不是表单校验不是表格分页而是文件上传。Element UI的Upload组件看着人畜无害可真要把文件稳定传到腾讯云COS同时还要兼顾进度条、回显、删除、权限校验一整套链路攒下来中间能出的幺蛾子比想象中多得多。这篇就围绕一个很常见的需求展开在Element UI项目里用Upload组件选文件用axios向后端换上传凭证再借助COS SDK把文件直传对象存储。读完你至少能理清这三者的分工拿到一套可以直接落地改的代码也知道哪些坑是控制台看不出来的。1. 为什么我放弃Upload的action直传改用http-request自定义上传1.1 Upload自带的上传行为到底解决了什么问题很多刚接触Upload组件的朋友第一反应肯定是它自带一个action属性往里填个后端地址选完文件它自己就发请求了这不就完事了吗确实如果你们后端有一个非常标准的文件接收接口比如把multipart/form-data接过去存本地磁盘那action模式完全够用。组件内部会自己创建XMLHttpRequest自己维护进度自己把结果丢给on-success回调你几乎不用写什么额外代码。但这里有个隐含前提后端必须愿意并且有能力收这个文件。一旦涉及云存储你会发现这个前提变得很尴尬。文件要落到腾讯云COS通常有两条路前端把文件POST给后端后端再转存到COS。开发简单但文件流要走你们自己的服务器带宽、超时、磁盘占用全压在业务机器上。传一个几百MB的视频后端接口能卡到怀疑人生。前端拿着临时凭证直连COS文件压根不过业务服务器。这种方式对后端友好也更符合对象存储的设计初衷但Upload组件默认行为完全不知道COS的存在。你选哪条路直接决定了后面所有代码的写法。我见过不少团队先用方案一走通结果线上传大文件把服务器带宽打满又回头重写成方案二。所以我个人的意见是如果项目里已经明确用了COS那就从一开始走直传后端只负责签发临时密钥不碰文件本体。1.2 后端转发COS的代价远不止带宽有人可能会反驳文件不大转发就转发呗也就几十兆。这里的问题不只是带宽。你想想后端接文件的时候需要设置上传大小限制吧Nginx有client_max_body_sizeJava的Spring有multipart.max-file-sizeNode的Express有body-parser限制。你每加一个限制都是在给极端情况埋雷。今天传50MB没问题明天产品说视频要支持500MB你就得改后端配置、调超时时间、考虑分片整个链路所有环节都要跟着动。而直传COS的话后端只需要提供一个无状态的签名接口。文件多大、传多久业务服务器根本无感知。前端和COS之间走得越顺畅后端越省心。从架构上看上传链路被拆成了两条凭证链路前端到后端轻量和数据链路前端到COS高带宽。这才是对象存储该有的用法。1.3 三者的职责边界Upload负责交互axios负责凭证COS SDK负责搬运把问题拆开事情就清晰了角色职责关键点Element UI Upload组件文件选择、文件列表、上传触发、回调展示不直接发请求用http-request接管axios请求后端签名接口换取临时密钥不负责传文件本体cos-js-sdk-v5真正把文件传到COS桶里依赖临时密钥完成鉴权这个分工的好处是每一层都很薄。Upload组件不需要知道COS的细节它只管把file对象交出来等我们手动调用onSuccess或onError告诉它结果axios不需要关心文件内容它要的只是那几个凭证字段COS SDK也不需要关心你用的什么UI框架拿到文件就往桶里塞。如果你把这三者的边界拎不清很容易写出用Upload的action指向一个后端转发接口再用axios拦截上传结果的杂交代码——不是说不能用而是排查问题时你分不清到底是哪一环出了问题。我以前就见过一个项目上传失败后前端、后端、运维各查各的最后发现是COS桶的跨域没配前端在组件和axios之间找了两天都没找到原因。这个坑后面在第五章专门展开。2. axios拿临时密钥、COS SDK传文件签名链路与凭证设计2.1 为什么前端不能直接用永久密钥讲签名链路之前先说说一个很多人栽过的念头COS的SecretId和SecretKey就那两串字符我直接把它写进前端代码里用COS SDK一初始化想传哪个桶传哪个桶多省事。这个念头会带来两个问题。第一是泄露风险前端代码本质上是公开的打包后的JS里搜一下字符串密钥直接就暴露了别人拿着它可以对你桶里的文件做任意读写账单也由你来扛。第二是权限粒度永久密钥是账号级的你没法单独限制它只能上传某个桶的某个目录也没法让它在两小时后自动失效这对权限管理来说几乎是裸奔。所以正规做法是让后端去申请临时密钥。腾讯云提供了STS服务后端用自己的永久密钥换一组有时效的临时凭证返回给前端。前端拿着临时凭证去初始化COS SDK有效期通常可以设为900秒到几个小时过期之后自动失效哪怕被截获了风险也可控。2.2 先用axios封装一个签名接口后端怎么接腾讯云的STS这里不展开我们只需要约定一个接口的返回结构。我常用的格式大致是这样POST /api/upload/cos-temp-key { code: 0, message: success, data: { tmpSecretId: AKIDxxxxx, tmpSecretKey: xxxxxxxxx, sessionToken: xxxxxxxxx, startTime: 1700000000, expiredTime: 1700003600 } }前端这边直接用项目里现有的axios实例封装一下就行不需要额外引库import request from /utils/request let authPromise null export function getCosAuth() { if (authPromise) { return authPromise } authPromise request({ url: /api/upload/cos-temp-key, method: post }).then((res) { if (res.data.code ! 0) { authPromise null return Promise.reject(new Error(res.data.message)) } return res.data.data }).catch((error) { authPromise null return Promise.reject(error) }) return authPromise }这里有一个细节值得多提一句我把authPromise做成了模块级缓存。为什么因为用户一次选择多个文件时Upload组件的http-request会被调用多次如果每个文件都重新请求一次签名接口后端瞬间会收到一堆重复请求。用Promise做缓存第一个请求发出去之后后续的调用直接复用同一个Promise等第一个响应回来大家都能拿到同一份凭证。这个写法其实是个很经典的前端异步缓存技巧不只是签名接口能用任何短时间内多个调用者共享一个请求结果的场景都适用。2.3 getAuthorization的调用时机别在每次上传时都现拉密钥COS SDK初始化时支持两种传密钥的方式。一种是直接在构造函数里把凭证字段填进去const cos new COS({ SecretId: tmpSecretId, SecretKey: tmpSecretKey, SecurityToken: sessionToken })这种写法适合一次性使用但临时密钥会过期一旦过期整个实例就废了。另一种是传getAuthorization回调SDK在每次发请求前都会调用它让你有机会动态返回凭证const cos new COS({ getAuthorization(options, callback) { // 返回临时密钥 callback({ TmpSecretId: tmpSecretId, TmpSecretKey: tmpSecretKey, SecurityToken: sessionToken, StartTime: startTime, ExpiredTime: expiredTime }) } })这里的StartTime和ExpiredTime不是随便填的SDK会根据它们判断当前凭证是否有效。如果还没到StartTime或者已经过了ExpiredTimeSDK会直接报错。所以后端返回的startTime和expiredTime要原样传进去不要自己瞎改。但这里也有个新问题getAuthorization每次上传都触发如果你的实现是调用后端接口那相当于把签名请求从每批文件一次退化成了每个文件一次。我的做法是在getAuthorization回调里不要直接发请求而是复用前面封装好的getCosAuth函数配合Promise缓存同一时间段内只产生一次真实请求const cos new COS({ getAuthorization(options, callback) { getCosAuth().then((auth) { callback({ TmpSecretId: auth.tmpSecretId, TmpSecretKey: auth.tmpSecretKey, SecurityToken: auth.sessionToken, StartTime: auth.startTime, ExpiredTime: auth.expiredTime }) }).catch((err) { callback(err) }) } })还要注意一个问题临时密钥过期后缓存里的Promise必须失效。所以缓存不能是永久性的我通常会在getCosAuth内部判断一下当前时间和expiredTime的距离距离小于60秒时就主动重置authPromise为null让下一次调用重新走后端。3. 核心实现http-request里跑putObject上传3.1 组件层面的关键配置签名的链路理清了现在真正进入Upload组件的核心改造。先看模板部分el-upload refuploadRef v-model:file-listfileList :http-requesthandleHttpRequest :before-uploadhandleBeforeUpload :before-removehandleBeforeRemove :on-removehandleRemove :limit5 accept.jpg,.jpeg,.png,.pdf,.xlsx,.xls,.doc,.docx multiple el-button typeprimary点击上传/el-button /el-upload关键就在:http-requesthandleHttpRequest。这个属性的作用是让组件不再使用默认的ajax上传行为而是把上传那张牌交到你手里。你手上有options里面有file、onProgress、onSuccess、onError这几个方法和属性你要做的就是把文件传出去然后调对应的回调。这里的fileList我用了v-model:file-list双向绑定Element UI 2.15以上版本支持这种写法比旧的:file-listupdate:file-list顺手得多。如果你项目里的Element UI版本比较老记得升级一下组件库否则fileList的更新会偶尔不同步。3.2 完整的上传处理函数下面这段是我在实际项目里用的简化版去掉业务噪音后核心逻辑长这样import COS from cos-js-sdk-v5 import { getCosAuth } from /api/cosAuth let cosInstance null function getCosInstance() { if (cosInstance) { return cosInstance } cosInstance new COS({ getAuthorization(options, callback) { getCosAuth() .then((auth) { callback({ TmpSecretId: auth.tmpSecretId, TmpSecretKey: auth.tmpSecretKey, SecurityToken: auth.sessionToken, StartTime: auth.startTime, ExpiredTime: auth.expiredTime }) }) .catch((err) callback(err)) } }) return cosInstance } async handleHttpRequest(options) { const { file, onProgress, onSuccess, onError } options const cos getCosInstance() const key buildCosKey(file) cos.putObject({ Bucket: your-bucket-1250000000, Region: ap-guangzhou, Key: key, Body: file, ContentType: file.type || application/octet-stream, onProgress: (info) { const percent Math.min(info.percent * 100, 100) onProgress({ percent }) } }, (err, data) { if (err) { onError(err) return } onSuccess({ key, url: https://${data.Location}, name: file.name }) }) }这里要特别提醒的是ContentType这一行。很多同学忽略它结果就是文件确实传上去了COS控制台里也有但拿到的URL在浏览器里打开时直接变成了下载而不是预览。原因就是COS保存对象时如果没有正确的Content-Type元数据Web服务器就会用默认的application/octet-stream去响应浏览器看到这个类型自然就触发下载了。所以file.type要原样传给putObject如果拿不到type再用二进制流兜底。3.3 文件Key的生成规则buildCosKey这个函数说白了是决定文件在桶里存放路径的。我建议不管业务多简单都别直接把用户原始文件名当Key至少做两件事加日期目录加随机后缀。function buildCosKey(file) { const ext file.name.includes(.) ? file.name.slice(file.name.lastIndexOf(.)) : const date new Date() const y date.getFullYear() const m String(date.getMonth() 1).padStart(2, 0) const d String(date.getDate()).padStart(2, 0) const random Math.random().toString(36).slice(2, 10) return uploads/${y}${m}${d}/${random}${ext} }为什么加随机串而不是直接用文件名因为同一天内两个用户都上传一个项目资料.pdf的话第二个会把第一个覆盖掉。用时间戳随机串基本能保证唯一性。另外把原始文件名丢掉的后果是回显时需要另存一个字段来保存file.name所以前面onSuccess里我特意把name也带上了后面列表展示就会用到它。3.4 多文件上传的另一个隐蔽细节Upload组件在multiple模式下会并行触发多个http-request。如果你在handleHttpRequest里每次都new一个COS实例虽然不至于报错但等于把初始化成本重复了很多遍。所以我用了getCosInstance这个单例模式整个页面生命周期内只创建一个实例getAuthorization在内部自动处理签名刷新这样效率最高。另外如果你的文件名是中文COS的putObject在有些浏览器下会偶尔把Body当成字符串处理导致上传内容损坏。稳妥的做法是对文件名做一次URL编码或者在生成Key时用前面那种随机串替代中文名回显时再把原始名存在数据库。我推荐后者因为COS的Key本身也不建议放中文。4. 文件列表回显、进度换算与删除策略的实现细节4.1 进度条为什么老是在99%卡住Upload组件自带的进度条来自onProgress回调。COS SDK给出的info.percent是一个0到1的小数比如0.42表示42%。组件内部需要的percent字段是0到100的整数所以要做一步换算。我前面代码里写了Math.min(info.percent * 100, 100)这个min的兜底是必须的因为COS在某些临界情况下会把percent精确计算到1.000001这种值直接赋值给组件进度条会突然跳到超过100%看起来非常怪。还有一种情况是进度条到了99%就不动了要等好几秒才跳到100%。这一般不是我们的代码问题而是COS在上传完成后有一个服务端确认过程最后的握手阶段没有进度数据可报所以前端进度条会短暂停留在99%。遇到这种情况别慌最终onSuccess回调一定会触发触发之后组件会把文件标记为上传成功。4.2 回显时的fileList结构上传成功后Upload组件会根据onSuccess传进来的response更新fileList里对应项的response字段。回显无非两种场景上传过程中实时展示以及编辑页面打开时回填已有文件。实时展示最简单只要onSuccess返回的数据能标识出文件即可组件会自动把文件状态变成success。但编辑回显就麻烦一点因为Upload组件的fileList是它内部维护的你直接往数组里塞普通对象可不一定有效。正确的回显结构是这样的// 编辑页打开弹窗时 this.fileList [ { name: 原始文件名.pdf, url: https://your-bucket-1250000000.cos.ap-guangzhou.myqcloud.com/uploads/20250101/xxxxx.pdf, status: success } ]url字段是给预览用的status要用success否则组件会以为这个文件还在上传中。如果后端返回数据里正好有文件名和完整的COS地址那直接映射一下就好。我见过很多同学把数据库里的COS Key当成url回填结果整个列表都是裂图就是因为缺了域名前缀。更要注意的是如果这个upload放在弹窗里弹窗关闭再打开时fileList必须重新赋值。有些情况下你明明赋了值界面上就是不显示原因是Upload组件在弹窗销毁后还保留了上一次的渲染状态。这时候给el-upload加一个:keydialogVisible强制重新渲染一次就能解决。4.3 大小与类型校验到底写在哪层类型和大小校验我一般放在before-upload里。注意这个钩子有两个作用返回false可以阻止上传返回Promise则可以异步阻止或放行。如果你只是简单判断一下大小同步返回即可handleBeforeUpload(file) { const maxSize 50 * 1024 * 1024 const allowTypes [image/jpeg, image/png, application/pdf] if (file.size maxSize) { this.$message.error(文件不能超过50MB) return false } if (!allowTypes.includes(file.type)) { this.$message.error(仅支持jpg、png、pdf格式) return false } return true }这里有个概念要澄清HTML的accept属性只是控制文件选择框的筛选用户可以手动改成所有文件绕过它所以它不能当作安全边界。真正的类型校验还是要看before-upload甚至要看后端。如果你的业务文件类型比较复杂比如需要在上传前读一下文件头那就得返回一个PromisehandleBeforeUpload(file) { return new Promise((resolve, reject) { // 模拟异步校验逻辑 if (someCondition) { resolve(true) } else { this.$message.error(校验不通过) reject(false) } }) }注意reject触发的行为Element UI会把Promise被reject理解为上传失败从而走到on-error逻辑里。如果不想让组件重复弹错误提示你可以像我上面这样在reject之前自己弹一次Message然后reject一个不会触发额外提示的值。4.4 删除COS文件的两种策略Upload组件的before-remove和on-remove是处理删除的两个时机。很多人只关心文件列表里删掉一条记录却忘了COS上对应的对象还占着空间。今天就你家这个桶的存储量来说一次几MB无所谓但积少成多到月底看账单就心疼了。删除策略我推荐分两种场景如果文件是用户临时上传、还没提交表单的前端应该在on-remove里直接调COS的deleteObject帮后端省事。如果文件已经随表单一起提交入库了前端不要自己删而是只做列表变更等用户整体提交时告诉后端哪些旧文件需要清理由后端统一删。为什么后者不让前端删因为已提交的资源可能存在被其他地方引用的情况比如一个头像被多个业务引用了你前端直接删了COS对象后面想追溯都找不到。让后端在事务里确认没有引用后再删才是最稳的。handleRemove(file, fileList) { // file.response?.key 是上传成功后我塞进去的COS Key if (file.response file.response.key) { const cos getCosInstance() cos.deleteObject( { Bucket: your-bucket-1250000000, Region: ap-guangzhou, Key: file.response.key }, (err) { if (err) { console.warn(COS删除失败, err) } } ) } }5. 三个让上传看似成功实则失败的坑和排查链路5.1 跨域报错COS控制台的CORS配置不配就是白折腾你可能会遇到这种情况代码逻辑完全没问题但在浏览器里一点上传控制台就蹦出一行Access to XMLHttpRequest at https://xxx.cos.ap-guangzhou.myqcloud.com/... from origin http://localhost:8080 has been blocked by CORS policy。这个报错看起来像是代码问题实际上绝大多数情况是COS桶的跨域规则没配。你去腾讯云控制台找到对应的存储桶权限管理跨域访问CORS设置新增一条规则把允许的来源加上你的前端域名注意是协议域名端口全匹配比如http://localhost:8080别只写*生产环境最好精确到具体域名。AllowedMethod记得勾上PUT、POST、GET、DELETE因为putObject用的是PUT请求deleteObject用的是DELETE。AllowedHeader建议填*这样SDK带自定义Header时不会被浏览器拦截。这个配置改完一般一分钟内生效如果还报错先确认是不是浏览器缓存了旧的OPTIONS预检结果强刷一下再看。5.2 Content-Type被置空上传成功后图片却变成了下载这个问题我在3.2节提过一嘴但它的隐蔽性值得拿出来单讲。症状是这样的文件上传成功回显图片的URL前戳也拼对了但在新标签页打开时浏览器没有显示图片而是直接下载了一个无后缀名的文件。排查链路我建议这样走先看COS控制台里这个对象的Content-Type字段是什么。如果显示application/octet-stream那就证明上传时Metadata没设置对。再回头看你的putObject参数ContentType是不是漏了或者从file.type取到的值是空字符串。某些压缩包、特定格式文件的file.type就是空的所以取不到的时候要兜底成二进制的标准类型但如果是图片或PDF强烈建议手动指定为image/jpeg、application/pdf这种确切类型。还有一个小技巧如果你不想改前端代码重新传一遍可以在COS控制台里手动修改这个对象的Content-Type能立刻解决眼前的问题但这只适合补救根治还是要把代码补上。5.3 临时密钥过期隔了很久再上传直接403临时密钥默认几百秒到几小时有效期如果你页面一直开着超过了有效期再去上传COS会返回SignatureDoesNotMatch或Access Denied。但为什么你传一次就失败、没有自动刷新呢回到getAuthorization的实现如果你像我前面那样用了getCosAuth的Promise缓存而缓存里没有判断过期时间那同一份过期凭证会被反复使用当然每次都失败。所以正确缓存逻辑必须带时间判断。我给个清晰版本let authCache null let authExpireTime 0 export function getCosAuth() { const now Math.floor(Date.now() / 1000) if (authCache authExpireTime - now 60) { return Promise.resolve(authCache) } authCache null authExpireTime 0 return request({ url: /api/upload/cos-temp-key, method: post }).then((res) { const data res.data.data authCache data authExpireTime data.expiredTime return data }) }留出60秒的余量是为了避免刚好卡在过期瞬间去请求省得有边界问题。改完这个即使你的tab挂了两小时再上传第一个请求触发getAuthorization时会发现凭证临近过期自动重新拉取后面的请求就都正常了。5.4 弹窗内Upload初始回显丢失最后一个坑可能只有频繁做弹窗表单的人才会碰到。Upload放在el-dialog里第一次打开对话框回显正常关闭后再打开文件列表空了。你这是不是也在疑惑fileList的data明明已经重新赋值了啊这个现象的本质是弹窗组件没有销毁Upload的内部状态还停留在上一次关闭时的样子。你赋fileList的时机在弹窗open之后但组件没有重新渲染或者说它内部对fileList做了缓存。解决手段简单粗暴我上面提过给el-upload加一个:keydialogVisibledialogVisible是一个每次打开都变的布尔值或弹窗ID。key一变Vue就会把Upload组件整个销毁重建内部状态全部清空这时候再赋fileList就能稳稳地渲染出来。如果你不想加key也可以用this.$nextTick配合手动重置fileList但没有key那种一击必杀的确定性。最后再说一个这些年攒下来的小经验凡是涉及COS直传的项目我都习惯在Upload的on-error回调里把COS返回的原始错误对象原样打印出来。COS SDK的错误码跟HTTP状态码不是一一对应的Access Denied有可能是密钥过期、也有可能是桶权限不足还有可能是你压根没配置SecurityToken转发。前端排查这类问题时先把错误日志留住再对照COS的服务端错误码文档逐条排除比瞎猜稳得多。上传这块慢就是快把每一环的日志补全了后面能省下大把联调时间。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表