Fork me on GitHub

微信小程序文件下载实战: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:

  1. 推荐:后端用 EasyExcel / Apache POI 直接生成 .xlsx,前端只是把 fileType 改成 'xlsx'——前端代码一行不用改。
  2. 不推荐:前端用 exceljs 现场生成。行数一多就卡主线程,包体积也顶不住。

七、上线前自测清单

复制这一份,逐项打勾再提测:

功能

平台

边界


一句话总结:小程序下载文件 = 拿内容 → 写沙盒 → openDocument(showMenu: true),全程原生 API,零依赖。真正要花心思的不是代码,而是和产品对齐"文件最终存在哪"、以及别忘了配域名白名单。

posted @ 2026-09-20 16:59  极度恐慌_JG  阅读(21)  评论(0)    收藏  举报