我们团队最近在做一个社区类APP用户需要拍照、选图上传头像和发动态后端一开始的方案是客户端把图片Base64发给服务器再由服务器转存到OSS。结果联调阶段就出问题了用户选一张3MB的照片Base64编码后体积膨胀30%多弱网环境下传一张图要十几秒服务器内存也吃紧高峰期CPU直接飙到告警线。后来改成uniappvue3客户端直传阿里云OSS整个流程顺畅多了图片上传耗时从十几秒降到两秒以内服务器负载几乎为零。这篇文章就把这套方案完整拆开讲。先说清楚这篇文章适合谁看正在用uniappvue3开发APP、需要实现图片上传功能尤其是想把上传压力从业务服务器转移到对象存储的同学。文章会把前端签名、客户端直传、图片压缩、上传进度、常见坑全部讲透你照着做基本能少走一周弯路。1. 内容整体设计与技术选型思路1.1 为什么最终选择uniappvue3OSS直传方案当时团队内部其实吵过一轮核心分歧是“图片到底要不要经过业务服务器”。第一种方案也就是最早的Base64中转方案优点是不用动OSS配置、后端对图片有完全的控制权可以顺便做鉴权和格式校验。但缺点非常致命Base64会把图片体积增大33%左右而且服务器要维护大量长连接和内存缓冲区并发一高就扛不住。更麻烦的是APP端的网络环境远比PC复杂弱网下的超时重传会反复消耗服务器资源。第二种方案客户端直传OSS业务服务器只负责签发上传凭证。图片数据直接从客户端流向OSS不经过业务服务器。这样有几个直接看得见的好处服务器不再处理文件流带宽和内存压力几乎为零上传速度更快因为OSS边缘节点离用户更近上传和下载可以走CDN加速后续扩展也方便。最终选择直传方案还有一个特别现实的原因我们用uniappvue3开发这套技术栈最大的优势就是一套代码可以同时编译到iOS、Android、小程序和H5。如果走Base64中转每个端都要单独处理文件读取逻辑而使用OSS SDK或者REST接口直传各端的差异只集中在文件路径获取和上传调用这两层封装一次就能到处复用。1.2 直传方案的完整链路设计直传OSS并不是说客户端拿个AccessKey就直接往里扔那样等于把云账号的钥匙挂在门上。正规做法是引入STS临时凭证机制。整个链路是这么设计的客户端发起上传请求前先调用业务服务器的签名接口。业务服务器收到请求后校验用户登录态然后调用阿里云STS服务的AssumeRole接口获取一个临时AccessKeyId、临时AccessKeySecret和SecurityToken。业务服务器把这三个值连同OSS Bucket名称、上传目录、过期时间一起返回给客户端。客户端拿着临时凭证拼接出上传地址直接通过uni.uploadFile把图片传到OSS。上传完成后客户端把返回的文件URL提交给业务服务器业务服务器再写入数据库。这套流程里客户端永远接触不到主账号的AccessKey就算临时凭证泄露了有效期过了就失效而且可以通过RAM策略限制只能上传到指定目录影响面可控。1.3 技术栈各环节的选型理由uniapp部分使用的是vue3语法组合式API因为vue3的script setup写法比vue2的选项式API简洁得多尤其是处理上传这种涉及多个状态变量文件列表、上传进度、上传结果的场景Composition API可以把相关逻辑内聚在一起不用在data、methods、watch之间来回跳。阿里云OSS这边采用的是PutObject直传而不是表单上传PostObject。原因是APP端用uni.uploadFile时PostObject的多表单字段拼接过程比较麻烦而且遇到特殊字符时容易踩坑。PutObject的签名URL方式更直接——把要上传的ObjectKey和临时凭证计算出一个带签名的URL客户端拿这个URL直接发PUT请求语义清晰调试也方便。有一点要注意uniapp的uni.uploadFile默认发的是POST请求如果要走PUT方式传OSS需要自己用uni.request配合method: PUT来实现。这个细节后面代码部分会详细讲。2. 环境准备与OSS核心配置2.1 阿里云OSS侧需要做哪些准备先到阿里云控制台开通OSS服务创建一个Bucket。创建时有几个选项容易被忽略但实际上很关键。读写权限建议选择“私有”。很多人图省事选“公共读”这样上传后的图片确实可以直接通过URL访问不用再走签名但带来的风险是如果链接被爬虫抓到存储费用和流量费用都会失控。我们的做法是Bucket保持私有上传完成后通过CDN或者服务端签名URL对外提供访问安全性和成本都可控。服务端签名的时候需要用到RAM子账号。千万别直接用主账号的AccessKey主账号权限太大一旦泄露整朵云都危险。创建一个RAM用户只授予OSS上传权限策略可以精确到指定的Bucket路径例如允许对myapp-bucket/uploads/*执行oss:PutObject操作。这一步做好后面就算客户端被逆向拿到临时凭证也只能往指定目录塞文件不能读、不能删、不能越权。跨域规则也要配置。APP端上传请求的Origin和OSS不在同一个域如果不配置跨域规则H5端或者部分WebView环境会直接失败。在Bucket的“跨域设置”里添加一条来源为*或者你的APP域名允许GET, PUT, POST, DELETE暴露ETag响应头。APP端多数时候不受跨域限制但加上这步能保证同一套代码编译到H5时依然可用。2.2 业务服务器端的STS签名实现业务服务器我们用的是Node.js核心任务是接收客户端请求、调用STS服务获取临时凭证、返回给客户端。这里给出一个精简的实现用的是阿里云官方SDKalicloud/sts20150401。// 文件: stsService.js const StsClient require(alicloud/sts20150401).default; const OpenApi require(alicloud/openapi-client); const config { accessKeyId: process.env.OSS_ACCESS_KEY_ID, accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET, endpoint: sts.cn-hangzhou.aliyuncs.com, }; const stsClient new StsClient(new OpenApi.Config({ accessKeyId: config.accessKeyId, accessKeySecret: config.accessKeySecret, endpoint: config.endpoint, })); exports.getStsToken async (userId) { const assumeRoleRequest new StsClient.AssumeRoleRequest({ roleArn: process.env.OSS_ROLE_ARN, roleSessionName: app_${userId}_${Date.now()}, durationSeconds: 900, // 15分钟有效够传完图了 }); const response await stsClient.assumeRole(assumeRoleRequest); const { credentials } response.body; return { accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, securityToken: credentials.securityToken, expiration: credentials.expiration, bucket: process.env.OSS_BUCKET, region: process.env.OSS_REGION, uploadPath: uploads/${userId}/${Date.now()}/, // 按用户隔离目录 }; };这里有个设计细节值得展开为什么有效期只设15分钟因为临时凭证最长可以到1小时但凭证有效期越长泄露后的风险窗口就越大。用户从点选图片到上传完成一般也就几十秒15分钟完全够用。如果用户磨蹭太久导致凭证过期重新调用一次签名接口即可成本很低。2.3 后端签名URL生成逻辑拿到了STS临时凭证之后客户端还需要一个“签名后的上传地址”。这个地址可以在后端直接算好返回给客户端也可以在客户端用临时凭证自己算。为了减少客户端的计算逻辑我们选择在后端直接生成带签名的上传URL和ObjectKey。// 文件: ossService.js const OSS require(ali-oss); exports.getUploadUrl async (userId, fileName, ext) { const sts await stsService.getStsToken(userId); const client new OSS({ region: sts.region, bucket: sts.bucket, accessKeyId: sts.accessKeyId, accessKeySecret: sts.accessKeySecret, stsToken: sts.securityToken, }); const objectKey ${sts.uploadPath}${Date.now()}_${fileName}.${ext}; const url client.signatureUrl(objectKey, { method: PUT, expires: 900, }); return { uploadUrl: url, objectKey, }; };signatureUrl生成的是一个包含签名参数的完整URL客户端直接对这个URL发PUT请求就行。这样前端就完全不关心签名算法、不关心临时凭证怎么拼拿到URL只管传出错概率大大降低。3. 前端uniappvue3核心实现3.1 选择图片前的权限处理APP端选图片涉及相册权限Android和iOS行为不同这块是新手最容易忽略的。uniapp提供了uni.authorize和uni.getSetting但实际使用中有一个坑APP端如果没有声明对应权限授权调用会静默失败。在manifest.json的App模块权限配置里必须勾选“相册相机胶卷”。Android还需要在Android权限配置里确保包含以下权限声明{ androidPermissions: [ android.permission.READ_EXTERNAL_STORAGE, android.permission.CAMERA ] }iOS在Info.plist里要加NSPhotoLibraryUsageDescription否则一调用相册直接闪退。描述文案别写得太随意审核的时候会看这个。权限申请的前端代码如下// 文件: usePermission.js export function usePermission() { return new Promise((resolve, reject) { // 先检查当前授权状态 uni.getSetting({ success: (res) { const authSetting res.authSetting; // 如果已经授权过直接返回 if (authSetting[scope.album] true) { resolve(true); } else if (authSetting[scope.album] false) { // 之前拒绝过引导用户去设置中心开启 uni.showModal({ title: 提示, content: 需要相册权限才能上传图片请前往设置开启, success: (modalRes) { if (modalRes.confirm) { uni.openSetting({ success: () resolve(true) }); } else { reject(new Error(用户拒绝授权)); } } }); } else { // 还没申请过主动发起授权 uni.authorize({ scope: scope.album, success: () resolve(true), fail: () reject(new Error(授权失败)) }); } }, fail: () reject(new Error(获取设置失败)) }); }); }这段代码覆盖了三种状态未申请过、曾拒绝、已授权。测试几次就会发现很多APP在用户第一次拒绝后再也弹不出授权框原因就是没有引导用户去uni.openSetting。3.2 图片选择与压缩的完整流程选择图片用uni.chooseImage它可以一次选多张也可以指定sourceType为相册或相机。但在实际项目里我推荐配合uni.compressImage做一层压缩因为现在手机拍出来的照片动辄5MB往上直接传OSS不仅慢还浪费存储和CDN流量。// 文件: upload.js export function chooseAndCompressImages(count 9) { return new Promise((resolve, reject) { uni.chooseImage({ count, sizeType: [compressed, original], sourceType: [album, camera], success: async (res) { const tempFiles res.tempFiles; const processedFiles []; for (const file of tempFiles) { try { const compressedPath await compressImage(file.path); processedFiles.push({ path: compressedPath, size: file.size, name: file.name || generateFileName(file.path) }); } catch (e) { // 压缩失败时退回原图别让用户卡在这一步 processedFiles.push({ path: file.path, size: file.size, name: generateFileName(file.path) }); } } resolve(processedFiles); }, fail: reject }); }); } function compressImage(path) { return new Promise((resolve) { uni.compressImage({ src: path, quality: 80, success: (res) resolve(res.tempFilePath), fail: () resolve(path) }); }); }重点说下quality的选择。80这个值我们测过一轮对普通照片来说质量几乎无损肉眼看不出来对验证码截图、二维码这类高对比度图片80会稍微出现一点点锯齿但不影响识别。如果项目对图片清晰度要求高可以调到90但体积会增加一截看你更在意体验还是成本。3.3 图片上传到OSS的代码实现接下来是核心环节把压缩后的图片通过uni.uploadFile传到OSS。前面提到过我们用的是PUT方式所以这里不能用uni.uploadFile而是用uni.request配合method: PUT发送二进制内容。有同学可能会问uni.uploadFile就支持POST上传为什么非要用PUT因为OSS的PostObject表单上传需要把策略、签名、Key、File全部拼成表单字段而uni.request的PUT请求只需要把文件内容放body里URL里带签名就够了代码更少出错概率也更低。// 文件: upload.js export function uploadToOss(filePath, uploadUrl) { return new Promise((resolve, reject) { uni.request({ url: uploadUrl, method: PUT, data: filePath, // 关键让request把人文件二进制而不是按普通参数序列化 header: { Content-Type: application/octet-stream }, success: (res) { if (res.statusCode 200) { resolve(res); } else { reject(new Error(上传失败: ${res.statusCode})); } }, fail: (err) reject(err) }); }); }这个方法有个细节需要注意data直接传文件路径在H5端会出问题因为H5的uni.request不支持直接把本地路径当二进制发送。我们是在APP端使用所以没问题。如果你需要兼容H5就得改用uni.uploadFile配合POST表单上传写法会不一样。3.4 完整上传流程的状态管理单张图上传好办多张图上传就要管理并发和控制顺序。我们的做法是串行上传一次只传一张传完再传下一张。这样好处是进度条好画、错误好定位、服务器也不会被瞬间打爆。上传进度的展示依赖uni.uploadFile的onProgressUpdate回调但前面说了PUT方式没有这个回调。怎么办我们在实际项目里用一个笨但有效的方法如果上传张数多就在总进度上做模拟进度每传完一张进度加100/total如果单张图片很大做不到实时进度就在UI上显示“正在上传第N张”的文案配合旋转loading图标。实测用户对这个体验感知不差因为图片压缩后一般也就几百KB单张传输时间很短。完整的串联流程代码大概长这样// 文件: useUpload.js import { ref } from vue; export function useUpload() { const uploadProgress ref(0); const uploading ref(false); async function uploadImages(fileList) { uploading.value true; uploadProgress.value 0; const total fileList.length; const results []; for (let i 0; i total; i) { // 1. 请求签名 const signResult await getSignUrl(userInfo.value.id, fileList[i].name); // 2. 上传图片 const uploadResult await uploadToOss(fileList[i].path, signResult.uploadUrl); if (uploadResult.statusCode 200) { results.push({ url: getPublicUrl(signResult.objectKey), key: signResult.objectKey }); } // 3. 更新进度 uploadProgress.value Math.round(((i 1) / total) * 100); } uploading.value false; return results; } return { uploadProgress, uploading, uploadImages }; }3.5 预览与回显的图片地址转换上传成功之后不能直接把objectKey塞进image标签里就完事因为Bucket是私有的直接访问会返回AccessDenied。我们需要把私有读权限的图片URL转成可访问的地址有两种常见做法。第一种服务端每次返回图片时动态生成签名URL。这样做的好处是权限控制精确每个URL都有有效期缺点是要多一次网络请求。第二种前台上传完成后用临时凭证调用OSS的signatureUrl把私有URL转换成带签名参数的临时公网URL。我们用的是第二种因为流程短、前端自己就能完成。// 伪代码示意生成预览URL // 这里的client是前端拿的临时凭证初始化的OSS实例 export function getPreviewUrl(client, objectKey, expires 3600) { return client.signatureUrl(objectKey, { expires, // 1小时后过期体验期够长 }); }需要注意这个预览URL是有过期时间的不能直接存数据库当永久链接。我们的做法是数据库存objectKey前端展示时实时向后端换取签名URL。虽然每次展示图片都要多一次签名请求但安全性和灵活性都高得多。3.6 上传部分完整的页面示例把上面几段拼起来一个简易的发布动态页面核心逻辑大概长这样template view classpublish-container view classimage-grid view v-for(img, index) in imageList :keyimg.previewPath classimage-item image :srcimg.previewPath modeaspectFill / view classremove-btn clickremoveImage(index)删除/view /view view v-ifimageList.length 9 classadd-btn clickhandleChooseImage 添加图片 /view /view view v-ifuploading classprogress-bar text{{ uploadProgress }}%/text /view button :disableduploading clickhandlePublish发布/button /view /template script setup import { ref } from vue; import { chooseAndCompressImages } from /utils/upload; import { useUpload } from /composables/useUpload; import { getSignUrl, getPreviewUrl } from /api/upload; const imageList ref([]); const { uploading, uploadProgress, uploadImages } useUpload(); async function handleChooseImage() { const files await chooseAndCompressImages(9 - imageList.value.length); const previews files.map(file ({ ...file, previewPath: file.path })); imageList.value.push(...previews); } function removeImage(index) { imageList.value.splice(index, 1); } async function handlePublish() { if (!imageList.value.length) return; const uploaded await uploadImages(imageList.value, getSignUrl); console.log(uploaded results:, uploaded); // 把uploaded提交给业务服务器完成发布 } /script4. 常见问题与排查技巧实录4.1 上传一直报“InvalidAccessKeyId”这是把我坑得最惨的一个问题。第一次联调时后端返回的STS凭证明明是对的但客户端上传就是报InvalidAccessKeyId。后来排查发现临时凭证里的accessKeyId和accessKeySecret不能直接用还必须带securityToken。OSS SDK读取临时凭证时如果只填了AK/SK忘了把stsToken也设置进去就会报这个错。检查顺序建议是先确认后端是否完整返回了三个字段AK、SK、Token再确认前端初始化OSS Client时是否传了stsToken最后确认Token是否已经过期。很多时候是Token过期了但配置的有效期很长不好排查可以直接在初始化时打印日志。4.2 上传回调成功了但图片无法访问上传成功后拿到的URL打不开页面白屏或报AccessDenied。这个问题我们遇到过两次原因都是Bucket权限设置成了私有而前端直接用了不带签名的链接。解法前面也说过用signatureUrl生成带签名的临时链接。另外一种情况是你明明配了CDN加速但CDN回源没配好也会导致图片不出来。检查CDN时重点看回源HOST是否填对了Bucket域名以及是否存在回源鉴权配置。4.3 Android部分机型上传报错或闪退这个坑比较隐蔽。我们测试时发现某些国产ROM的WebView对uni.request发送二进制body的支持不完整小图片没问题大图片直接闪退。解决办法有两个方向一是把图片压缩得更狠一点单张控制在1MB以内二是放弃PUT方式改用uni.uploadFile配合PostObject方式上传。如果你坚持要PUT方式还可以试试把上传请求放到一个Web Worker里跑避免阻塞UI线程。不过这个方案在uniapp里实现起来麻烦兼容性也一般不如直接换上传方式省心。4.4 微信小程序端和APP端的行为差异虽然标题是APP但很多项目最后都会顺手接小程序。需要注意小程序的uni.request不支持直接发送本地文件路径作为body必须用uni.uploadFile。所以如果你的代码要同时跑APP和小程序建议把上传层抽象成接口内部判断是APP还是小程序再走不同的上传分支。4.5 常见问题速查表现象可能原因解决办法InvalidAccessKeyId未传SecurityToken或Token失效检查临时凭证三要素是否齐全刷新TokenAccessDeniedBucket私有URL缺签名用signatureUrl生成带签名链接上传超时图片过大或弱网开启压缩单张控制在2MB内上传成功后图片无法访问CDN回源配置错误检查回源HOST和回源鉴权Android闪退WebView二进制发送兼容性差改用PostObject上传方式相册打不开缺少相册权限声明检查manifest.json和Info.plist配置重复选择同一张图不触发changeuni.chooseImage对相同路径做了去重每次选图后清空filePath缓存或加随机参数5. 性能优化与经验总结5.1 图片上传前的体积控制策略OSS是按存储量和流量计费的图片越大成本越高。我们制定了一套比较实用的图片压缩策略单张图片超过500KB就压缩到80%质量超过2MB压缩到60%质量超过5MB直接拒绝上传引导用户重新选择。另外在UI上也做了配合图片网格的预览用的是压缩缩略图点击查看原图时才加载高清版本。这套策略执行下来用户上传的平均单张图片体积从2.3MB降到了370KB存储成本直接减少了80%多上传速度也快了一大截。5.2 并发上传的取舍与优化空间前面说我们用了串行上传这在多数场景下够用。如果你要追求更快可以做并发上传比如同时传3张。并发上传的核心逻辑是控制最大并发数避免一下把所有图片全部打出去。把串行改并发核心代码就是加一个任务队列控制。一个简化版的并发上传控制器可以这样写async function uploadWithConcurrency(fileList, limit 3) { const tasks fileList.map((file, index) ({ file, index })); const results new Array(fileList.length); let current 0; async function worker() { while (current tasks.length) { const task tasks[current]; const url await getSignUrl(task.file); results[task.index] await uploadToOss(task.file.path, url); } } const workers Array.from({ length: limit }, () worker()); await Promise.all(workers); return results; }实际测试中3并发下传5张图时长比串行快了约40%。但这会占用更多网络带宽如果你的APP要同时处理大量上传请求还是建议把并发数控制在2到3个不要贪多。5.3 APP端的缓存与重试机制弱网环境下传容易失败这时候不能直接告诉用户“上传失败”就完了要加重试机制。我们的做法是失败后自动重试2次间隔分别3秒和8秒如果两次都失败才把错误展示给用户。重试时要注意不要拿已经过期的签名URL去重试应该重新请求签名再上传。重试逻辑要处理好幂等性避免同一次上传产生多份重复文件。我们在生成ObjectKey的时候已经带了时间戳所以每次重试都会生成新的文件名数据库里不会出现覆盖导致脏数据。5.4 安全性细节补充整个方案里最需要重视的就是安全有几个细节值得再强调一遍临时凭证的RAM策略一定要最小化授权只给需要的Bucket和目录别图省事给oss:*权限。签名接口一定要有鉴权逻辑不能让未登录用户随便拿凭证。我们用的是JWT校验接口上线前还做了并发压测确保在高并发下STS服务不会被刷爆。上传文件后缀要做白名单限制后端生成ObjectKey时只允许jpg/jpeg/png/gif/webp防止恶意上传可执行文件。上传文件大小在签名阶段就要校验后端可以在STS策略里限制Content-Length范围防止超大文件塞爆存储。5.5 从上线后数据看方案效果这套方案上线运行三个月汇总一些实际运营数据供参考图片上传平均耗时从原来的14秒降低到2.1秒服务器从高峰CPU 85%降到了12%带宽占用降低了90%。用户反馈上传失败的投诉量从每月40条降到了每月不到5条大部分还是弱网环境第一次失败但重试成功后不再感知的那种。我觉得这套方案的真正价值不在于某个具体的技术点而在于把上传链路彻底解耦了。业务服务器不用再关心文件存储的细节专注做业务逻辑OSS负责存储和分发稳定性和弹性都有保证客户端拿到签名URL后上传失败重试也不会污染业务数据。如果你们的APP也有类似的图片上传需求按照这个思路改造前端工作量其实不大收益却非常直观。
阅读完成 · 觉得有帮助?