
开发小程序遇到文件上传下载不少人第一反应是这有什么难的Web 里用 input 选个文件FormData 一发就行。但真正做过一个带附件功能的小程序项目就会发现同样一个需求在小程序里被拆成了选文件、传文件、下文件、存文件、打开文件、授权、合法域名、临时目录七八个环节而且每个环节都有自己独立的坑。这篇文章就把这些环节挨个讲透顺便记录一下我这几轮项目里真正踩过的雷希望能帮正准备做这块功能的人少走一点弯路。1. 小程序文件传输到底在难什么三个基础限制先讲透我说句实在话如果你只是照着官方文档把wx.uploadFile和wx.downloadFile抄下来那大概率不能直接上线。小程序不是浏览器它有一套自己的文件处理和网络策略很多 Web 开发出身的人第一次接小程序文件功能往往就是被这套策略卡住的。1.1 沙盒文件系统临时文件不是“永久文件”小程序的文件系统是沙盒化的你的代码不能随便读写用户手机里的任意路径只能在它给你划出来的几个目录里操作。这个约束平时感觉不到但一旦涉及文件上传下载就会变得非常明显。比如你用wx.downloadFile下载一个文件默认情况下它返回的不是一个永久路径而是一个以wxfile://tmp_开头的临时路径。也就是说这个文件只是放在临时目录里了小程序重启、系统清理缓存、超过一定时间它都可能被回收。很多新手会把这个临时路径直接塞到全局变量或者数据库里过几天用户再点“打开”就发现文件不在了问题就出在这里。正确的做法是如果你想让这个文件长期存在必须把它从临时目录搬到本地用户目录比如复制到${wx.env.USER_DATA_PATH}/files/xxx.pdf然后再保存路径。这里我多说一句USER_DATA_PATH是微信小程序给你的本地用户目录正常情况下不会被清掉适合放业务文件。1.2 合法域名与网络白名单的隐性门槛小程序在真机上请求网络不是你想连哪就连哪的。所有wx.request、wx.uploadFile、wx.downloadFile要访问的域名都必须在小程序管理后台配置成合法域名。不上传证书文件基本配置不了而且要求必须是 HTTPS。这个门槛坑过非常多团队。开发工具里可以勾选“不校验合法域名”所以本地联调一切正常但一到真机测试或者发布体验版接口直接报错。你去看控制台错误信息往往就一句话url not in domain list。我建议任何项目从第一天就把这个事当成上线前提来做。域名提前申请、HTTPS 提前配好、后台白名单提前加上甚至可以把request、uploadFile、downloadFile三种合法域名一次性都配上。因为这三个接口共用一个域名配置列表你只配了request没配另外两个上传下载照样被拦。还有一个容易忽略的点上传下载接口的域名不能是 IP 地址也不能带端口号必须是 80/443 默认端口。如果你们团队的接口还跑在http://192.168.1.10:8080这种地址上那小程序是永远联不通的只能用于内网联调。1.3 上传下载 API 不是全局函数是“封装上的封装”用过 Web 的人可能觉得文件上传就是一个请求的事但小程序里wx.uploadFile做了很多隐性的封装它帮你处理了 multipart 格式帮你拼接了请求头也帮你约束了超时时间。也就是说你没法像 axios 那样自由控制底层 XMLHttpRequest也没法轻易添加自定义的流式处理。这意味着当你遇到“上传大文件超时”“上传没有进度条”“服务端拿不到自定义 header”这类问题时不能按 Web 的老办法去猜得顺着小程序 API 的封装逻辑来排查。后面我写上传部分时会把参数逐项拆开讲因为很多失败原因就是某个参数用得不对而不是接口本身有问题。2. 文件上传从用户选择到服务器收到文件的完整链路上传是整个链路里最容易出问题的环节因为涉及三个端点用户手机上的源文件、小程序运行环境、你的服务端。任何一个环节配合不好用户看到的就是一个永久转圈的上传按钮。2.1 文件从哪儿拿三种选择 API 的区分与适用场景微信小程序里选文件有好几个 API虽然看起来差不多但适用场景完全不同我先把常用的列出来做个对比。API能选什么适用场景注意点wx.chooseMedia图片、视频头像、相册上传、短视频返回临时文件对象含路径和大小wx.chooseMessageFile聊天会话里的文件、图片、视频等从微信聊天中选择文档、PDF、Excel这是很多人忽略的入口支持选择任意格式文件wx.chooseImage只选图片老项目、图片上传新版建议改用chooseMediawx.chooseVideo只选视频视频上传同样建议并入chooseMedia我自己的习惯是做一个通用上传组件时优先考虑业务里到底需要用户传什么。如果只是传图片用wx.chooseMedia如果用户需要上传文档、压缩包那必须走wx.chooseMessageFile否则你根本拿不到 PDF 文件。比如一个合同管理类小程序用户要把手机里收到的合同 PDF 传上来那入口只能是wx.chooseMessageFile。代码大概是这样的wx.chooseMessageFile({ count: 1, type: file, // 只选文件过滤图片和视频 success(res) { const file res.tempFiles[0]; console.log(file.name, file.size, file.path); // 这里拿到的 file.path 就是待上传文件的临时路径 } });注意res.tempFiles是一个数组里面每个元素有name、size、path三个字段。name就是文件名这个信息很重要因为临时路径里的文件名通常是一串乱码或者编码后的字符串你不把原始文件名传给服务端服务端保存文件时就只能瞎猜扩展名。2.2 核心上传调用wx.uploadFile 的参数、进度与回执拿到临时路径以后上传的核心就是wx.uploadFile。我直接给一个相对完整的示例参数说明写在代码里const task wx.uploadFile({ url: https://api.example.com/v1/files/upload, filePath: file.path, // 必填本地文件路径 name: file, // 必填后端接收文件的字段名 header: { Authorization: Bearer ${token} }, formData: { bizType: contract, fileName: file.name, // 原始文件名要单独传服务端才能正确落盘 fileSize: file.size }, timeout: 30000, success(res) { // 注意这里是 HTTP 状态码不是业务状态码 if (res.statusCode 200) { // res.data 是服务端返回的字符串需要自己解析 const data JSON.parse(res.data); if (data.code 0) { console.log(上传成功文件ID是, data.data.fileId); } } }, fail(err) { console.error(上传失败, err); } }); // 监听上传进度 task.onProgressUpdate((res) { console.log(res.progress, res.totalBytesSent, res.totalBytesExpectedToSend); });有几个点我必须单独强调。第一个是name字段。这个字段决定了服务端用哪个参数名接收文件比如你用multer做 Node 服务端中间件里写的是upload.single(file)那这里的name就必须填file。填错了服务端会提示没有收到文件。第二个是formData。这里适合放一些和文件关联的业务参数比如业务类型、用户 ID、订单号。但注意formData里的值会被放进 multipart 的普通字段区不是放进 HTTP 请求头里。如果你需要把自定义信息放在请求头里得用header二选一别搞混。第三个是进度监听。wx.uploadFile返回的是一个UploadTask对象调用它的onProgressUpdate可以拿到实时进度。但这里有个平台差异微信基础库 2.1.0 之后才能用版本太低的用户会拿不到回调。如果你要兼容老旧版本就得自己做个兜底比如上传期间显示一个不精确的百分比动画。第四个是success回调里的res.data。服务端返回的是一段字符串不是对象必须JSON.parse。还有statusCode 200只代表 HTTP 层成功服务端如果判断你没有权限照样可能返回 200 但里面带一个错误码。所以业务是否成功一定要解析res.data之后再去判断。2.3 多文件、多格式的业务化封装需要同时上传多个文件时很多人会自然地用Promise.all把若干个wx.uploadFile一起发出去。这种做法在文件少、体积小的时候没问题但文件一多问题就来了。我做过一个项目用户一次要传 30 多张工地照片最早就是Promise.all一次性全发。结果就是手机 CPU 和网络被瞬间占满上传进度条在一个个跳而且服务端那边的 Nginx 连接数瞬间飙高甚至有几张照片在中间环节超时失败。后来我改成了并发数控制的方案每次最多同时上传 3 个一个完成后再把队列里的下一个推上去。代码大致是这样的思路async function uploadFiles(fileList, concurrency 3) { const results []; let index 0; async function worker() { while (index fileList.length) { const file fileList[index]; try { const res await uploadOne(file); results.push({ name: file.name, ok: true, fileId: res.data.fileId }); } catch (e) { results.push({ name: file.name, ok: false, error: e }); } } } const tasks Array.from({ length: Math.min(concurrency, fileList.length) }, worker); await Promise.all(tasks); return results; }这个思路值得你套用到自己的代码里。并发数不一定要 3可以根据你的服务端压力和文件平均大小来调但一定不要无限制并发。另外每个文件都单独走wx.uploadFile可以让失败重试只重试单个文件不会因为一个文件挂了拖累整批。2.4 上传失败的现场还原网络、超时、身份过期上传失败时最忌讳只看fail里的错误对象因为那些错误信息非常粗往往只告诉你uploadFile:fail timeout或者request:fail根本看不出是哪一层出的问题。我自己的排查顺序是先看本地文件路径是否存在。用wx.getFileSystemManager().access判断临时路径还在不在如果文件本身已经没了那上传必然失败。再看法庭域名和 HTTP 层。如果错误是url not in domain list那是配置问题如果是statusCode 403/401那是鉴权问题。看服务端日志。很多上传失败实际上是服务端在解包 multipart 时出错比如文件大小超了 nginx 的client_max_body_size这时候前端只会收到一个 413 或 500你要是光看前端肯定找不出原因。最后才考虑超时。小文件 10 秒都传不完往往是网络问题大文件默认超时 60 秒也不够需要调大timeout参数或者走分片。还有一种特别隐蔽的情况用户上传到一半切到后台小程序被系统挂起网络请求被新任务打断。这种失败从代码上很难完全避免比较好的处理方式是上传结果里带一个重试按钮让用户手动重新触发而不是自动无限重试。3. 文件下载临时路径如何变成可复用、可打开、可分享的文件下载看起来只是把网络请求反过来但小程序里它同样藏着不少操作细节。核心问题就一个你下载下来的文件到底应该放在哪里才能让用户正常打开、再次访问、甚至转发给别人。3.1 downloadFile 拿到的是临时路径下一步有讲究最基本的下载写法是这样wx.downloadFile({ url: https://api.example.com/v1/files/download?id123456, header: { Authorization: Bearer ${token} }, success(res) { if (res.statusCode 200) { console.log(res.tempFilePath); // res.tempFilePath 就是临时路径 } } });如果你只需要当场打开一次那直接用res.tempFilePath是可以的。但问题在于绝大多数业务都不是“当场打开一次”就行。用户下载个表格下次可能还要从历史记录里看下载个合同可能过几天还要转发。如果只存临时路径那这些需求全都满足不了。wx.downloadFile其实还支持传入filePath参数允许你把文件直接下载到你指定的本地路径这样就能绕开临时目录const targetPath ${wx.env.USER_DATA_PATH}/files/contract-a.pdf; wx.downloadFile({ url: https://api.example.com/v1/files/download?id123456, filePath: targetPath, success(res) { if (res.statusCode 200) { // 此时文件已经写到 targetPath console.log(保存成功路径, res.filePath); } } });这里有个小坑filePath指定的路径如果已有同名文件默认是会覆盖的。如果你不希望覆盖旧文件最好自己先查一下路径是否存在再决定是否拼接时间戳之类的后缀。3.2 保存到本地saveFile 与 FileSystemManager 的分工如果你想下载完成后把文件从临时目录复制到本地用户目录早期官方给的方式是wx.saveFile。它很简单但限制也大你没法自由指定目标路径文件会被存到一个系统管理的wxfile://store_目录下大小和总容量都有限制。新版基础库里wx.saveFile慢慢退居幕后我更推荐用wx.getFileSystemManager()提供的saveFile或者copyFile手动管理。举个例子下载完成后我想把文件归档到本地目录并记录一个稳定的路径const fs wx.getFileSystemManager(); wx.downloadFile({ url: https://api.example.com/v1/files/download?id123456, success(res) { if (res.statusCode ! 200) return; const tempPath res.tempFilePath; const dirPath ${wx.env.USER_DATA_PATH}/files; const targetPath ${dirPath}/123456.pdf; // 先确保目录存在 fs.access({ path: dirPath, success() { copyFile(tempPath, targetPath); }, fail() { fs.mkdir({ dirPath, recursive: true, success() { copyFile(tempPath, targetPath); } }); } }); } }); function copyFile(src, dest) { fs.copyFile({ srcPath: src, destPath: dest, success() { console.log(复制成功, dest); } }); }这套逻辑比wx.saveFile直观的地方在于路径完全由你控制后续做文件清理、断点续传、缓存管理都方便得多。我强烈建议凡是下载后要长期使用的文件都走到这一步来。3.3 打开与预览openDocument 和相册保存的差异文件下载到本地以后接下来用户要打开。不同文件类型有不同处理方式文档类PDF、Word、Excel、PPT用wx.openDocument它可以直接打开并支持右上角菜单转发非常适合合同、报表、说明书场景。图片可以用wx.previewImage预览也可以用wx.saveImageToPhotosAlbum保存到相册。视频用wx.previewMedia或者wx.saveVideoToPhotosAlbum保存到相册。wx.openDocument有个参数值得记住showMenu: true。这个参数控制打开文档后右上角是否有转发菜单。很多人不传用户打开文件后发现没法转发又来找你说“怎么不能分享”其实就是这个布尔值没设。还有fileType参数。如果你知道文件类型最好显式传给openDocument比如fileType: pdf或fileType: xlsx。不传的话小程序会根据文件扩展名自己猜有时候扩展名不标准或者缺失就会打不开。所以下载时一定要把文件名和扩展名信息一起保存下来后面打开才能少踩坑。3.4 一个下载模块的完整代码骨架把前面这些综合起来我常用的下载模块长这样function downloadAndOpen({ fileId, fileName }) { const fs wx.getFileSystemManager(); const dirPath ${wx.env.USER_DATA_PATH}/downloads; const targetPath ${dirPath}/${fileId}_${fileName}; return new Promise((resolve, reject) { // 如果已经存在直接打开不再发起网络请求 fs.access({ path: targetPath, success() { openLocalFile(targetPath, fileName); resolve(); }, fail() { ensureDirAndDownload(fileId, fileName, targetPath, resolve, reject); } }); }); }这里其实我加了一个小优化下载前先检查本地是否已经有这个文件有就直接打开。这样可以避免用户反复进入详情页就重复下载同一份文件既省流量又省时间。4. 服务端接口约定上传下载能不能用得久一半看前端一半看后端前端做得再顺服务端接口设计不合理照样会在上线后频繁出问题。很多团队的前后端在文件上传下载接口上各写各的等到联调才发现字段对不上再返工。我分享几个我踩完坑之后固定下来的约定。4.1 稳定的接口字段路径、方法、响应体怎么设计上传接口和下载接口我建议遵循这么几条约定上传统一用POST路径形如/v1/files/upload。下载统一用GET路径形如/v1/files/{fileId}/download或者/v1/files/download?fileIdxxx。响应体统一返回 JSON不要返回裸文件流也不要只返回一个 HTTP 200 就了事。上传接口的响应体我建议这样设计{ code: 0, message: ok, data: { fileId: a8f3b9e2c1d4, fileName: 合同.pdf, size: 102400, url: https://cdn.example.com/files/a8f3b9e2c1d4.pdf } }下载接口的响应可以直接返回文件流但也要约定好错误格式比如鉴权失败时不能返回一个 HTML 错误页面。前端拿res.statusCode只能看到 HTTP 层状态真正有用的业务错误码还是要放在 JSON 里。字段里有一个容易漏的点url。服务端最好返回一个可直接访问的 CDN 地址这样前端可以在必要的时候交给wx.downloadFile或者wx.openDocument去用。但要注意如果这个 URL 是公开的就存在被爬走或盗链的风险后面讲安全的时候我会多说一句。4.2 鉴权与防滥用令牌、签名、临时凭证文件接口最容易出现的滥用方式是上传一个恶意文件或者不停请求下载接口把服务器流量刷爆。所以上传下载接口一定要做鉴权不能裸奔。常见做法是请求头里带Authorization: Bearer token服务端先校验登录态再校验用户对当前资源是否有权限。如果你用的是云函数中转更复杂的项目还会做临时凭证前端先请求业务接口拿到一个一次性上传凭证。带凭证调wx.uploadFile服务端校验凭证有效且未过期。下载时同理前端拿到的下载链接里带一个有效期很短的签名参数。这个思路在服务端实现起来其实就是签发一个带过期时间的 HMAC 签名或者一段短的 JWT。好处是即使链接被截获过几分钟也失效了不会被人拿去长期刷文件。4.3 大文件与分片什么时候该上怎么上得体不是所有文件都适合一个wx.uploadFile直接传。超过几十兆的文件尤其视频、安装包走着走着就容易超时断掉。平台默认超时 60 秒只是一个因素手机系统在弱网环境下切换网络也会中断请求。遇到大文件的时候我建议上分片上传。思路是前端把文件切成固定大小的分片比如每片 2MB。逐片上传同时记录上传进度。全部传完以后调一个merge接口通知服务端把分片合并。分片的大小怎么定我一般这么算单次请求在 5 秒内能传完的大小比较稳。按照弱网环境 200KB/s 的速率估算2MB 大概要 10 秒有点悬4G 网络稍微快一点3MB 比较折中。但分片也不是越小越好片数太多会导致服务端合并时产生大量磁盘读写和网络请求开销。我自己的经验是普通大文件用 2MB视频类用 4MB 到 5MB。分片上传的复杂度明显高于单文件上传你还需要考虑断点位置记录也就是已经传过的分片不要重传。我建议项目里如果只是偶尔传个小视频可以先不上分片但如果用户要传十几分钟的现场录像一定要提前规划分片能力否则上线后肯定被投诉。5. 踩坑实录五个真实场景的排错链路这部分我挑几个真实项目里反复出过的坑把排查过程写出来比直接给答案更有价值。5.1 坑一订单号开头的中文文件名在服务端大面积损坏现象用户上传一个名为“报价单-深圳-0710.pdf”的文件服务端存下来的文件名变成了乱码或者干脆是“-深圳-”这种被截断的内容。排查了一圈发现不是扩展名的问题而是编码问题。我当时是怎么查的先在前端打印wx.chooseMessageFile返回的file.name中文显示正常。再用formData把fileName传过去抓包一看formData里的中文经过小程序网络层传输时被按照默认编码处理了服务端拿到的字节流已经变了样。解决思路有两个我更推荐第二个前端先把文件名做一次encodeURIComponent服务端拿到后再解码。前端完全不传文件名服务端根据文件二进制内容里的 magic number 判断真实类型然后自己生成一个统一的文件标识名。比如 PDF 以%PDF开头图片有对应的文件头这样更可靠不会受中文编码干扰。当然另一种更省事的方案是把原始文件名作为 JSON 字段写在res.data里返回给前端前端展示时直接用返回值不依赖服务端磁盘文件名。5.2 坑二下载后的文件第二次打开就失效现象第一次通过wx.downloadFile下载的文件当场用wx.openDocument能打开。用户退出小程序再进来从历史记录里点击同一个下载项却提示“文件不存在”。排查链路我先看历史记录里存的路径。果然里面存的是res.tempFilePath。这个路径在小程序冷启动后大概率已经不可用因为临时目录被系统清理过了。修复方式就是前面说的那套“复制到用户目录”的逻辑把临时文件归档到${wx.env.USER_DATA_PATH}下历史记录里存归档后的路径。这样每次用户点击先做一次本地路径存在性检查不存在就重新downloadFile存在就直接打开。5.3 坑三并发上传把服务端打挂前端却显示成功现象用户一次传 30 张照片前端全部执行了success回调页面也提示“全部上传成功”但后端只落了一部分文件还有几个文件在网关层报了超时。我当时的排查链条是这样先看服务端日志发现超时的请求都是排在最后面的几张而且耗时都超过了 30 秒。再看前端代码用的是Promise.all一次性发 30 个请求。问题就很清楚了同一时间并发太多单张照片 2MB 左右手机上行带宽就这么点30 个请求同时挤每个请求都被拖慢网关等待响应超过阈值直接断开连接但小程序这边有的请求已经被服务端处理完了只是响应没回来所以前端误判为失败另一些则是前端发了但服务端没收到前端也不知道。修复就是我前面说的并发数控制同时把前端提示改成“已提交 20/303 张失败可重试”而不是笼统地说全部成功。5.4 坑四iOS 和 Android 对同一文件的处理结果不一致现象同一个 PDF 文件iOS 上打开正常Android 上wx.openDocument一直白屏。后来发现根本不是文件内容的问题而是 Android 端对fileType参数更敏感。排查中发现iOS 上即使你不传fileType系统也能根据路径后缀自动识别但 Android 某些版本上如果扩展名和文件头不匹配或者路径里带着临时目录生成的随机后缀系统就识别不了直接拒绝打开。修复方式在wx.openDocument调用前把文件路径改成带明确扩展名的新路径比如contract_a8f3.pdf并显式传入fileType: pdf。所以我前面一直强调要自己控制目标路径就是这个原因。5.5 坑五白名单配置错误导致所有 uploadFile 静默失败现象开发版一切正常体验版上传总是“fail”但下载却能用。控制台里没有任何 403 或者 domain 报错看起来就像是接口没响应。最后查到的问题很典型后台的合法域名只配了downloadFile对应的域名开发版勾选了“不校验合法域名”所以本地能跑体验版强制校验后uploadFile域名不在列表里就直接失败了。由于微信的错误提示有时候不会明确指出来很多开发者会往传参方向排查白浪费一两个小时。给你一个检查清单真机测试前把request、uploadFile、downloadFile三种合法域名全部检查一遍而且要逐个确认有没有漏配。这个动作应该写进上线 checklist 里不要每次临时抱佛脚。6. 上线前最后再检查一遍体验优化与安全策略功能能跑通只是第一步真正决定这个功能好不好用的还是各种细节。6.1 用户视角的进度、取消、重试用户上传一个 100MB 的视频时如果只有一个转圈动画他根本不知道还要等多久很容易中途退出。一定要显示进度条或者百分比。前面讲过UploadTask.onProgressUpdate可以拿到进度你可以把它包装成一个可取消的任务用户点击“取消”时触发一个中断状态然后调用task.abort()注意这个方法是存在的不要自己在业务里屏蔽它。失败重试也要做得轻量。单个文件失败后不要弹窗一个冷冰冰的“上传失败”最好在文件卡片上显示一个“重试”按钮让用户只重传这一个文件而不是整批再来一遍。6.2 文件清理与存储占用控制小程序的本地存储空间有限尤其 iOS 上很容易到几百 MB 就提示存储不足。所以下载文件时一定要想清楚这个文件是不是必须长期保留我的做法是给本地文件打一个时间戳和来源业务 ID定期清理 30 天以上没用过的文件。清理的时候用fs.unlink删掉对应路径同时要把数据库里的本地路径清掉下次用户点击时再走一遍下载逻辑。如果文件很多还可以做一个“手动清理缓存”的入口让用户在设置页里一键清理。6.3 敏感文件的安全策略如果你上传的文件是合同、身份证照片、体检报告这类敏感信息那还要额外注意几点不要在前端写死云存储的公钥或者密钥权限校验放在服务端。下载链接必须带鉴权参数最好是一次性短时效的签名链接不能直接返回公开 CDN 地址否则别人拿到 URL 就能看很吓人。上传前做文件类型校验不要只信前端的file.name后缀要让服务端根据文件头或者二进制内容判断真实类型防止别人传一张伪装成 PDF 的可执行文件。6.4 个人落地建议最后聊聊我自己的习惯。做小程序文件功能这几年我最大的体会是先把文件生命周期理清楚再写代码。所谓生命周期就是回答几个问题文件从哪里来选中之后临时状态多久有效上传完成后服务端返回什么标识下载后放在本地哪个目录用户下次打开时还在不在什么时候清理只要这几个问题都有明确答案代码怎么组织反而不难。难的是很多人一开始只想到“上传”和“下载”两个动作忽略了文件在本地和服务端的中间状态后面接二连三出问题。如果你正准备动手做我给你一个简单的起步建议先做单文件上传下载和本地归档跑通以后再加多文件并发控制和断点续传。不要一上来就追求大而全文件功能出问题的时候定位难度远比你想象的复杂。稳扎稳打做完一个版本再逐步升级反而更省时间。