微信小程序文件下载实战:uni-app + Vue3 从 0 到上线
场景来源:某企业级小程序的一个业务列表页 → 点击「下载单据」→ 打开 Word 文档 → 用户转发 / 用其他应用打开。
该方案已在生产环境验证通过。
目标:看完你能直接抄,少走一周弯路。
0. TL;DR(急性子看这段)
小程序里下载文件的完整链路只有三步:
// 1. 接口拿 base64
const res = await http.post('/api/xxx/renderDoc', { bizId, docType })
// 2. base64 写进小程序用户目录(第三个参数 'base64' 千万不能漏!)
const fs = uni.getFileSystemManager()
const filePath = `${uni.env.USER_DATA_PATH}/单据.docx`
fs.writeFileSync(filePath, res.row, 'base64')
// 3. 打开文档,showMenu: true 才有右上角"转发/用其他应用打开"
uni.openDocument({ filePath, fileType: 'docx', showMenu: true })
就这些。没有三方库,没有 Blob,没有 a 标签。
如果你照着写却打不开文件,99% 是下面三个原因之一:漏了 'base64'、漏了 showMenu、或者文件路径不在小程序沙盒里。详见 第五章踩坑清单。
一、技术栈
1.1 本文实现所用技术栈
| 层 | 选型 | 说明 |
|---|---|---|
| 框架 | uni-app 3.x | 一套代码多端;本文只针对微信小程序端 |
| 视图 | Vue 3.4 + <script setup> |
Composition API |
| 语言 | TypeScript 5.x | 接口出入参有类型 |
| 构建 | Vite 5 | uni-app 官方 Vite 插件链 |
| 模板 | unibest | 开箱即用的 uni-app 工程模板 |
| UI | uview-plus | 本文的下载流程本身不依赖任何 UI 库 |
| 状态 | pinia 2 | 表单状态管理 |
| 请求 | 自封装 http.ts(基于 uni.request) |
统一鉴权 / 错误处理 |
| 包管理 | pnpm | — |
| 目标端 | 微信小程序 | 本文所有原生 API 均为微信端 |
💡 版本只保留大版本号即可。写精确到构建号的版本,对读者没有额外价值,反而暴露了自己生产环境的依赖指纹。
1.2 ⚠️ 反直觉的第一课:这个功能不需要任何三方库
如果你是从 Web 转过来的,第一反应大概是找库:file-saver、xlsx、exceljs、axios 配 Blob……
这些在小程序里全都用不了,一个都别装:
| Web 方案 | 小程序里的下场 |
|---|---|
new Blob([...]) |
❌ 没有 Blob 构造函数 |
URL.createObjectURL(blob) |
❌ 没有这个 API |
<a download href="..."> 点击下载 |
❌ 没有 DOM,没有 a 标签 |
file-saver |
❌ 依赖上面三个,全套失效 |
axios 配 responseType: 'blob' |
❌ 小程序请求是 uni.request / wx.request |
xlsx / exceljs 前端生成 Excel |
⚠️ 部分能跑(纯 JS 计算),但大文档会卡死主线程 + 撑爆包体积,生产不建议 |
根本原因:小程序是双线程架构(逻辑层 JSCore / V8 + 渲染层 WebView),逻辑层没有 DOM、没有 BOM;文件系统是沙盒隔离的,不存在用户可见的"下载文件夹"。
所以小程序的文件下载,本质是:
拿到文件内容 → 写进自己的沙盒目录 → 调用系统能力打开它,让用户通过「转发 / 用其他应用打开」把文件带走。
这三步微信原生 API 已经全覆盖,引入三方库只会增加包体积和排查成本。
什么时候才真的需要库?
仅当你必须在前端生成/编辑 Excel(如本地合并表格、样式化导出)时,才考虑exceljs(比xlsx的社区版更适合生产,样式支持好)。
但强烈建议让后端生成(Apache POI / docx4j / EasyExcel 等),前端只负责下载——本文的场景就是后端模板渲染后回传 base64,前端零负担。
二、选型:两条路,选错能卡你一整天
小程序下载文件只有两条技术路线,先搞清楚你的后端是哪种,再动手。
方案 A:后端给「文件 URL」→ uni.downloadFile
uni.downloadFile({
url: 'https://your-domain.com/file/123.docx', // 后端给的可访问链接
success: (res) => {
uni.openDocument({ filePath: res.tempFilePath, showMenu: true })
},
})
后端直接吐文件流(Content-Type: application/octet-stream),或给一个 OSS/COS 的临时签名 URL。
方案 B:后端给「base64」→ 写盘再打开(本文方案)
const res = await http.post('/api/xxx/renderDoc', { bizId }) // JSON 里塞 base64
uni.getFileSystemManager().writeFileSync(filePath, res.row, 'base64')
uni.openDocument({ filePath, fileType: 'docx', showMenu: true })
后端把文件内容编码成 base64 字符串,挂在 JSON 响应的某个字段里回传。
对照表
| 维度 | 方案 A:downloadFile | 方案 B:base64 |
|---|---|---|
| 后端改动 | 需要独立文件服务 / 文件流接口 | 现有 JSON 接口加个字段即可 |
| 域名白名单 | ⚠️ 需要额外配置 downloadFile 合法域名 | ✅ 复用已有的 request 合法域名 |
| 前端复杂度 | 简单(一行 downloadFile) | 中等(要自己写盘) |
| 传输体积 | 原文件大小 | 原文件 × 1.33(base64 膨胀) |
| 大文件表现 | ✅ 流式,不占内存 | ⚠️ 整个文件进内存 + JSON 解析 |
| 文件名控制 | 由 URL / Content-Disposition 决定 | ✅ 完全自己命名 |
| 断点续传 | ✅ downloadFile 支持 | ❌ 不支持 |
怎么选?
后端能提供一个"公网可访问的文件 URL"吗?
├─ 能 → 方案 A(更标准,大文件友好)
└─ 不能(文件由业务接口即时渲染,或不想暴露文件服务)
→ 方案 B(本文)✅
选 B 的典型场景很实际:文档是按需渲染的(内容取决于单据当前状态),没有落文件的必要,且不想为它单独开一套文件服务 + 域名白名单。
🔥 方案 A 最大的坑:合法域名是「两套独立白名单」
这是无数人栽跟头的地方,必须单独说:
微信小程序后台的「开发管理 → 开发设置 → 服务器域名」里,request / socket / uploadFile / downloadFile 是四个相互独立的白名单。
✅ https://api.xxx.com 已配置为 request 合法域名
❌ https://api.xxx.com 但没配 downloadFile 合法域名
→ uni.request() 正常,uni.downloadFile() 直接失败
最阴的地方在于:微信开发者工具默认勾选了「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」,所以本地开发一切正常;一旦上传体验版/正式版,或者真机调试时取消勾选,downloadFile 立刻失败。
踩过的都懂:上线当天才发现,回头再走一遍域名配置审批(需要域名备案 + 校验文件),又是一天。
方案 B 完全没有这个问题——它走的是 uni.request,域名早就配好了。
三、三个核心 API(记住这三个就够)
3.1 uni.env.USER_DATA_PATH — 沙盒里的"我的文档"
小程序的文件系统是沙盒隔离的,分四类目录:
| 目录 | 路径常量 | 特点 |
|---|---|---|
| 代码包文件 | — | 只读,随代码包发布 |
| 本地临时文件 | — | 用完即删,随时可能被系统清理 |
| 本地缓存文件 | wx.env.USER_DATA_PATH |
可读写,小程序本地文件存储上限 10MB |
| 本地用户文件 | wx.env.USER_DATA_PATH |
同上,开发者可自由读写 |
实际开发中你只要记住一个:
uni.env.USER_DATA_PATH
// 形如:wxfile://usr —— 一个固定的、可读写的沙盒目录
uni.env.USER_DATA_PATH与wx.env.USER_DATA_PATH在微信端指向同一个位置,uni.前缀由 uni-app 转译,两者可混用。
必须知道的限制:
- 🚫 用户看不到这个目录!它不是手机的"下载"文件夹,用户在文件管理器里永远找不到。
- 📦 本地文件总容量上限 10MB,超了写入直接失败。
- 🧹 不清理就永远在(除非用户删除小程序 / 清理微信缓存),反复下载会撑爆配额。
这是新手最大的认知误区:以为「保存到 USER_DATA_PATH = 保存到手机」。
不是的。USER_DATA_PATH 只是「暂存区」,真正的"保存"动作必须由用户在openDocument的菜单里完成。
3.2 writeFileSync(filePath, data, encoding) — 把 base64 变成真文件
const fs = uni.getFileSystemManager()
const filePath = `${uni.env.USER_DATA_PATH}/单据.docx`
fs.writeFileSync(filePath, base64Str, 'base64') // ← 第三个参数是命根子
为什么第三个参数不能漏?
writeFileSync 默认按 utf-8 文本写入。docx / xlsx / pdf 都是二进制文件,把 base64 字符串当文本写进 .docx,得到的是一个「用记事本打开全是乱码」的假文件——文件大小正常、后缀正常、双击就是打不开。
传 'base64' 时,文件系统会先把 base64 解码成二进制再写入,这才是正确的文件内容。
| 第三个参数 | 效果 |
|---|---|
不传(默认 utf-8) |
❌ 写入 base64 文本本身,文件损坏 |
'base64' |
✅ 解码后写入二进制,文件正常 |
'binary' |
用于 ArrayBuffer(如 downloadFile 拿到的数据),不适用于 base64 字符串 |
同步 vs 异步:
fs.writeFileSync(path, data, 'base64') // 同步,阻塞逻辑层
fs.writeFile({ filePath, data, encoding: 'base64', success, fail }) // 异步,推荐大文件用
文档通常只有几十 KB,writeFileSync 的阻塞可以忽略;如果文件可能到 MB 级,改用异步 writeFile。
3.3 openDocument({ filePath, fileType, showMenu }) — 打开并给用户"带走"的入口
uni.openDocument({
filePath, // 必须是【小程序沙盒内的本地路径】
fileType: 'docx', // 文件类型
showMenu: true, // 🔥 不传就只能在页面里干看,无法保存/转发
fail: (err) => console.error('打开失败', err),
})
参数说明
| 参数 | 必填 | 说明 |
|---|---|---|
filePath |
✅ | 只能是小程序中的本地文件路径(用户文件目录 / 临时文件)。传网络 URL、传手机绝对路径都会失败 |
fileType |
⚠️ 建议传 | 支持的格式:doc docx xls xlsx ppt pptx pdf。显式传可避免部分机型识别失败 |
showMenu |
✅ 强烈建议 | 是否显示右上角菜单,默认 false。这个菜单是用户唯一的导出通道 |
success / fail / complete |
建议传 | 失败时给用户反馈,否则静默失败 |
showMenu: true 到底做了什么?
它会让文档预览页右上角出现 ··· 菜单,里面有:
- 转发给朋友 — 直接把文件发给微信好友
- 用其他应用打开 — 交给 WPS / 系统 App 打开,进而可存到手机
- 保存到手机(部分机型 / 系统)
不传 showMenu,用户就只能在这个预览页里干看,什么都带不走——这是"功能明明实现了,用户却说没法保存"的头号原因。
打开后长什么样?(产品同学最关心的)
| 平台 | 用户点"保存/用其他应用打开"后 |
|---|---|
| Android | 一般进「文件管理」的微信目录,或直接用 WPS 打开并另存 |
| iOS | 受沙盒限制,通常存进「文件」App → 我的 iPhone → 微信 目录下,不是"下载"文件夹 |
⚠️ 这不是 bug,是微信/iOS 的既定行为。请提前和产品对齐预期,否则一定会有"用户找不到文件"的反馈单。
四、完整实现(可直接抄)
4.1 先跟后端约定接口契约
前端写死一半的锅都是这里没谈清楚。把下面这几条发给后端:
| 项 | 约定 |
|---|---|
| 响应格式 | JSON(不是文件流),文件内容放在 row 字段 |
| base64 形态 | 纯 base64 字符串:不要 data:application/xxx;base64, 前缀,不要换行、不要空格 |
| 业务码 | 沿用你的项目约定(本文以 result === '1' 表示成功,失败给 msg) |
| 文档格式 | 明确是 docx 还是 xlsx/pdf,前端据此设后缀和 fileType |
| 体积 | 尽量小;base64 会放大 33%,且整个字符串要进 JSON 内存 |
| 空文件 | 没内容时请返回业务失败码 + msg,不要返回空字符串(否则会写出一个 0 字节的坏文件) |
4.2 API 层
import { http } from '@/http/http'
/** 下载单据(返回 base64) */
export function renderDoc(params: { bizId: string, docType?: string }) {
return http.post<{ result: string, msg: string, row: string }>(
'/api/xxx/renderDoc',
params,
)
}
4.3 页面调用
<script setup lang="ts">
import { ref } from 'vue'
import { renderDoc } from '@/api/xxx'
import { openDoc, writeBase64File } from '@/utils/download'
const downloading = ref(false)
async function downloadDoc(item: { bizId: string }) {
const bizId = item.bizId || ''
if (!bizId) {
uni.showToast({ title: '缺少单据ID', icon: 'none' })
return
}
// 防重复点击:小程序里用户手速很快,且重复写盘会占配额
if (downloading.value)
return
downloading.value = true
uni.showLoading({ title: '生成中...', mask: true })
try {
const res = await renderDoc({ bizId, docType: 'typeA' })
// 双重校验:业务码 + 非空内容
if (res.result !== '1' || !res.row) {
throw new Error(res.msg || '生成失败')
}
const filePath = writeBase64File(res.row, `${bizId}.docx`)
uni.hideLoading() // ⚠️ 必须先 hideLoading 再 showToast / openDocument
openDoc(filePath, 'docx')
}
catch (e: any) {
uni.hideLoading()
uni.showToast({ title: e?.message || '下载失败', icon: 'none' })
}
finally {
downloading.value = false
}
}
</script>
4.4 抽成工具函数(推荐,附完整容错)
把通用逻辑收进 src/utils/download.ts,其他页面直接复用:
/** openDocument 支持的格式 */
const DOC_EXTS = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf'] as const
export type DocExt = typeof DOC_EXTS[number]
/**
* 把 base64 字符串写入小程序用户目录
* @param base64 纯 base64(兼容 dataURI 前缀与换行)
* @param fileName 文件名,含后缀,如 'DOC20240101.docx'
* @returns 沙盒内的本地文件路径
*/
export function writeBase64File(base64: string, fileName: string): string {
// 容错 1:后端可能返回 dataURI,把前缀切掉
const pure = base64.includes(',') ? base64.split(',')[1] : base64
// 容错 2:部分库生成的 base64 每 76 字符换行,必须清掉空白
const data = pure.replace(/\s/g, '')
const fs = uni.getFileSystemManager()
const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`
// 同名旧文件先删掉,避免反复下载撑爆 10MB 配额
try {
fs.unlinkSync(filePath)
}
catch {
// 文件不存在,属正常情况
}
fs.writeFileSync(filePath, data, 'base64')
return filePath
}
/** 打开文档(带失败反馈) */
export function openDoc(filePath: string, ext: DocExt = 'docx') {
uni.openDocument({
filePath,
fileType: ext,
showMenu: true, // 用户唯一的导出入口,不能省
fail: (err) => {
console.error('[openDoc] fail:', err)
uni.showToast({ title: '文件打开失败,请重试', icon: 'none' })
},
})
}
/**
* 一键:base64 → 写盘 → 打开
* 调用方负责 showLoading / hideLoading 与业务码校验
*/
export function openBase64Doc(base64: string, fileName: string, ext: DocExt = 'docx') {
return openDoc(writeBase64File(base64, fileName), ext)
}
页面里就只剩三行:
const res = await renderDoc({ bizId })
if (res.result !== '1' || !res.row) throw new Error(res.msg || '生成失败')
openBase64Doc(res.row, `${bizId}.docx`, 'docx')
💡 这里刻意让文件名不带时间戳(用业务 ID 即可):同名文件会被上面的
unlinkSync覆盖,天然避免了 10MB 配额被慢慢吃满。
注意openDocument正在打开的文件不要立刻删,所以是「打开前删旧的」而不是「用完删」。
五、踩坑清单(我替你踩过了)
按「现象 → 根因 → 解决」排列,前三条覆盖 90% 的故障。
| # | 现象 | 根因 | 解决 |
|---|---|---|---|
| 1 | 文件能生成、大小也正常,但打不开 / 提示文件损坏 | writeFileSync 漏传第三个参数,base64 被当 utf-8 文本写入 |
补上 'base64' |
| 2 | openDocument 打开无反应或报错 |
filePath 不在小程序沙盒内(传了网络 URL 或手机绝对路径) |
路径必须是 uni.env.USER_DATA_PATH/xxx 或临时文件路径 |
| 3 | 能预览,但用户存不下来 / 找不到保存按钮 | showMenu 没传,默认 false |
传 showMenu: true |
| 4 | 开发者工具正常,真机/体验版失败 | downloadFile 走的是独立的合法域名白名单 |
方案 A 才需配;或改用方案 B(走 request 域名) |
| 5 | 错误提示一闪而过看不见 | 先 showToast 再 hideLoading,二者在微信端同层,loading 会把 toast 一起关掉 |
先 hideLoading(),再 showToast() |
| 6 | 下载几十次后突然全部失败 | 用户目录本地文件上限 10MB,且每次用带时间戳的文件名从不清理 | 固定文件名 + 写盘前 unlinkSync 旧文件 |
| 7 | 部分机型打不开,部分正常 | 文件名后缀与 fileType 不一致 |
二者保持一致,显式传 fileType |
| 8 | 写入成功但内容错乱 | 后端 base64 带了换行(每 76 字符换行是常见编码习惯) | 写入前 replace(/\s/g, '') |
| 9 | 大文档下载时页面卡死 | writeFileSync 同步阻塞逻辑层 |
改异步 fs.writeFile + Promise 封装 |
| 10 | 用户狂点,产生一堆文件和重复请求 | 没做防重复 | downloading 标记位 / showLoading({ mask: true }) |
| 11 | iOS 用户反馈"文件找不到" | iOS 存到「文件」App 的微信目录,不是「下载」 | 提前和产品/用户对齐,属平台正常行为 |
| 12 | 想验证文件到底写没写进去 | — | 开发者工具 Console 里执行:uni.getFileSystemManager().readdirSync(uni.env.USER_DATA_PATH) |
| 13 | 开发者工具里打开效果和真机不一致 | 工具对 openDocument 的模拟不完整 |
必须真机验证,工具只能验证"文件写入成功" |
调试小技巧
// 1. 看用户目录里有哪些文件(验证写盘是否成功)
console.log(uni.getFileSystemManager().readdirSync(uni.env.USER_DATA_PATH))
// 2. 看文件多大(0 字节说明 base64 是空的)
const stat = uni.getFileSystemManager().statSync(`${uni.env.USER_DATA_PATH}/单据.docx`)
console.log('文件大小', stat.size)
// 3. 验证写入的是不是合法二进制(前几个字节应该是 PK,即 zip/docx 的魔数)
const buf = uni.getFileSystemManager().readFileSync(filePath, 'binary')
console.log(new Uint8Array(buf.slice(0, 4))) // docx/xlsx 正常应为 [80, 75, 3, 4]
六、扩展场景
6.1 后端给的是 URL → uni.downloadFile 版
export function downloadUrlFile(url: string, fileName: string, ext: DocExt) {
return new Promise<string>((resolve, reject) => {
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error(`下载失败:${res.statusCode}`))
return
}
// 临时文件会被系统回收,移到用户目录长期保存
const fs = uni.getFileSystemManager()
const target = `${uni.env.USER_DATA_PATH}/${fileName}`
try {
fs.copyFileSync(res.tempFilePath, target)
resolve(target)
}
catch (e) {
reject(e)
}
},
fail: reject,
})
})
}
⚠️ 别忘了配
downloadFile合法域名(见第二章「方案 A 最大的坑」)。
6.2 附件预览:arraybuffer 版(后端直接吐文件流)
适合 PDF / 图片等需要流式加载的场景:
uni.request({
url,
method: 'GET',
responseType: 'arraybuffer', // ← 关键:拿二进制而不是 JSON
success: (res) => {
const filePath = `${wx.env.USER_DATA_PATH}/preview_${Date.now()}.pdf`
wx.getFileSystemManager().writeFile({
filePath,
data: res.data, // ArrayBuffer
encoding: 'binary', // ← 注意这里是 binary,不是 base64
success: () => uni.openDocument({ filePath, showMenu: true }),
})
},
})
两种方案的本质区别:
| base64(本文) | arraybuffer(附件预览) | |
|---|---|---|
| 请求方式 | uni.request + JSON |
uni.request + responseType: 'arraybuffer' |
| writeFile 编码 | 'base64' |
'binary' |
| 传输膨胀 | ×1.33 | 无 |
| 适合 | 业务接口顺带回文件 | 专门的文件流接口、大文件 |
6.3 PC 端:直接存到用户电脑磁盘
如果你的小程序也在 PC 微信(Windows / macOS)上跑,wx.saveFileToDisk 可以把文件保存到用户电脑的文件系统(会弹系统保存对话框),这对 PC 端体验是质的提升:
wx.saveFileToDisk({
filePath: `${uni.env.USER_DATA_PATH}/单据.docx`,
success: () => console.log('已保存到电脑磁盘'),
fail: (err) => console.error(err),
})
该 API 仅 PC 端支持,移动端调用会失败。建议用条件编译隔离:
// #ifdef MP-WEIXIN // 结合 wx.getSystemInfoSync().platform 判断是否为 windows/mac // #endif
6.4 "我要的是 Excel,不是 Word"
本文场景的产出是 docx(模板化排版更好控制)。如果你确实需要真正的 Excel:
- 推荐:后端用 EasyExcel / Apache POI 直接生成
.xlsx,前端只是把fileType改成'xlsx'——前端代码一行不用改。 - 不推荐:前端用
exceljs现场生成。行数一多就卡主线程,包体积也顶不住。
七、上线前自测清单
复制这一份,逐项打勾再提测:
功能
平台
边界
一句话总结:小程序下载文件 = 拿内容 → 写沙盒 → openDocument(showMenu: true),全程原生 API,零依赖。真正要花心思的不是代码,而是和产品对齐"文件最终存在哪"、以及别忘了配域名白名单。


浙公网安备 33010602011771号