恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
云函数+excel-export实现小程序Excel导出实战
首页
资讯中心
/
云函数+excel-export实现小程序Excel导出实战
云函数+excel-export实现小程序Excel导出实战
发布时间:2026/10/17 10:09:55
这个需求我接过不止一次了运营要按天导出订单客户要把报名名单拉出来给讲师看管理员要把留言记录归档成 Excel 存档。在小程序里如果不想专门搭一套传统后端最省事的路线就是“云函数 excel-export”——云函数负责查数据库、拼表格、生成 xlsx 文件小程序端只负责接收文件并保存或转发。这篇我把从零实现到踩坑补漏的完整过程写出来适合刚接触小程序云开发、又想快速交付 Excel 导出功能的开发者参考。1. 为什么导出 Excel 这步必须放在云函数里1.1 小程序本地做不了真正的 Excel 导出很多人第一次接到导出需求时会想能不能在小程序端直接用 JS 生成 xlsx试过之后就明白了小程序运行环境比较受限没有 Node.js 的fs模块也没有 DOM 和Blob。虽然wx.getFileSystemManager()能写文件但写进去的内容得是现成的 ArrayBuffer 或者字符串不会凭空帮你把二维数组变成规范 xlsx。就算你强行引入一个纯 JS 的表格生成库在小程序包里跑起来也很吃力库文件体积大增加首包加载时间日期类型、单元格类型这些细节很难自己处理好写出来的文件还经常遇到 Excel 打开报错的情况。所以结论很直接Excel 的组装和生成放到云端做小程序端只做“接收文件”这一件事。1.2 为什么选 excel-export 而不是 xlsx / node-xlsx社区里常见的 Node 表格库有三个xlsxSheetJS、node-xlsx、excel-export。我最终选excel-export主要看中这几点对比项excel-exportxlsxnode-xlsx生成格式标准 xlsx内部走 zip 打包 XMLxlsx / csv 等xlsx原生依赖无安装即用无但体积偏大基于 SheetJSAPI 形态极简配置列头和数据即可需要理解工作簿/工作表概念简洁但样式控制少维护状态老项目多年没大更新社区版功能有限更新相对活跃excel-export的内部原理不复杂它用node-zip把 XML 内容压缩成 xlsx 包。生成的文件是真正的 xlsx 结构不是 CSV 改后缀Excel 和常见的国产表格软件都能正常打开。这对交付类需求特别重要——你永远不知道客户用的是哪个表格软件。还有一个实际原因它几乎没有学习成本。核心就是一个execute(conf)方法你给它列配置和行数据它返回一个 Buffer。数据量大一点也不会在小程序端卡界面。1.3 整体数据链路整个导出流程一句话就能说清小程序端调用云函数 → 云函数查数据库 → 把查询结果映射成二维数组 → 按列配置生成 xlsx Buffer → 转 base64 返回 → 小程序端解析并写入本地文件 → 预览或转发。这套链路里云函数是绝对核心。数据库访问、数据量控制、格式转换都在云端完成小程序端代码量可以压缩到很小。下面我从云函数端开始一步一步拆。2. 云函数初始化依赖安装、超时与内存的坑2.1 安装 excel-export 及依赖体积控制首先在云函数目录下执行npm install excel-export --save这个包发布很早依赖关系非常简单没有 node-gyp 编译步骤安装过程基本不会翻车。安装完成后package.json里会多出excel-export一项。提示部署时建议选择“上传并部署云端安装依赖”。这样本地不需要提交node_modules云端会按照package.json自动安装避免本地和云端 Node 版本不一致导致的问题。依赖体积方面excel-export加上node-zip也就几百 KB 级别对云函数冷启动影响很小。之前我看过同事在云函数里塞xlsx全量版冷启动直接多了一秒多相比之下excel-export用起来轻很多。2.2 超时与内存默认配置一定不够这是我第一次做导出时实实在在踩过的坑。云函数默认的超时时间很短导出几千行数据时还能勉强跑完一旦上万行经常直接报Function timed out。云函数目录下的config.json可以配置超时时间{ permissions: { openapi: [] }, timeout: 20 }timeout的单位是秒。我一般设置成 20 秒既能覆盖大多数导出场景又不会因为超时时间太长导致单个请求卡住云函数资源。改完config.json后要记得重新“上传并部署所有文件”只部署代码不部署配置的话新配置不会生效。内存方面我建议去云开发控制台的云函数配置页面把内存调成 256MB 或 512MB。原因后面讲excel-export是典型的“一次性把数据装进内存再加工”的模式导出数据量一大内存峰值会明显超过你的直觉。别为了省一点资源费用用 128MB 硬撑客户那边导出失败一次沟通成本远高于这点资源费。2.3 独立成一个云函数不要混进业务函数我习惯把导出逻辑单独放到一个云函数里比如取名getOrderExcel。原因很简单导出是重操作它要把大量数据读进内存、做格式转换耗时和资源占用都偏高。如果跟登录态校验、数据统计之类的轻逻辑混在同一个函数里轻逻辑很容易被拖慢出问题的时候排查范围也被放大了。独立云函数的另一个好处是方便控制并发和重试。万一某次导出数据量特别大我可以单独调这个函数的超时和内存不影响到其他接口。3. getOrderExcel 核心代码逐段拆解3.1 列格式配置决定 Excel 长什么样先看完整的云函数入口代码再来逐段讲关键点const cloud require(wx-server-sdk) const nodeExcel require(excel-export) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() exports.main async (event) { const { startDate, endDate } event // 1. 配置 Excel 列头 const conf { cols: [ { caption: 订单号, type: string, width: 24 }, { caption: 客户名称, type: string, width: 20 }, { caption: 商品名称, type: string, width: 30 }, { caption: 金额(元), type: number, width: 15 }, { caption: 下单时间, type: date, width: 22, format: yyyy-MM-dd hh:mm:ss } ], rows: [] } // 2. 按日期范围查询订单 const where {} if (startDate endDate) { where.createTime db.command.gte(new Date(startDate)).and(db.command.lte(new Date(endDate))) } const countRes await db.collection(orders).where(where).count() const total countRes.total const batchSize 1000 const rows [] for (let skip 0; skip total; skip batchSize) { const res await db.collection(orders) .where(where) .skip(skip) .limit(batchSize) .get() res.data.forEach(item { rows.push([ item.orderNo, item.customerName, item.productName, Number(item.amount), item.createTime instanceof Date ? item.createTime : new Date(item.createTime) ]) }) } conf.rows rows // 3. 生成 Excel 并返回 base64 const buffer nodeExcel.execute(conf) return { base64: buffer.toString(base64), count: rows.length } }这段代码里cols数组很关键它是 Excel 的“骨架”。每个字段对象包含四个属性caption表头显示的文字。type单元格数据类型常用string、number、date。width列宽单位是字符数不是像素。format仅type: date时有意义决定日期在 Excel 里的显示格式。这里最容易被忽略的是type。如果金额是数字但type写成string导出后 Excel 里虽然看着正常排序和求和会出问题因为存储的是文本而不是数值。用户拿到文件以后第一件事往往是求和到时候你就能收到“这个表是不是坏了”的反馈了。3.2 分批查询与二维数组组装云数据库单次get最多返回 1000 条记录所以数据量大时必须分批拉。我在代码里用了count skip循环的方式const countRes await db.collection(orders).where(where).count() const total countRes.total const batchSize 1000 for (let skip 0; skip total; skip batchSize) { const res await db.collection(orders) .where(where) .skip(skip) .limit(batchSize) .get() // 处理 res.data }skip循环的写法在万行级别完全够用。如果业务真的到了几十万行再考虑用_id游标方式翻页性能会好很多。不过真到那个量级我建议直接用后面讲的云存储中转方案别让用户干等一个几十秒的同步请求。关于日期字段有个细节值得单独说从数据库查出来的日期要在push进 rows 之前处理成 Date 实例。我看到过有人直接把 ISO 字符串传给excel-export结果 date 类型的单元格渲染异常。上面代码里我写了一个防御逻辑item.createTime instanceof Date ? item.createTime : new Date(item.createTime)这样可以同时兼容“数据库存的是 Date”和“数据库存的是字符串”两种情况。3.3 为什么返回 base64 而不是直接返回 Buffer云函数返回给小程序端的数据需要能序列化成 JSON。Buffer 不是 JSON 原生类型你直接return { file: buffer }小程序端拿到的会是一个带type: Buffer和一大串data数组的普通对象不仅难看还得自己重新组装。比较稳的做法是在云函数里转成 base64return { base64: buffer.toString(base64), count: rows.length }base64 会让数据体积膨胀约 1/3但换来的是小程序端解析逻辑非常简单不会遇到类型丢失问题。数据量在几千到一两万行时这个膨胀完全可以接受。4. 小程序端接收、落盘与文件分享4.1 调用云函数并解析 base64小程序端的调用代码非常短wx.showLoading({ title: 正在生成 Excel... }) const res await wx.cloud.callFunction({ name: getOrderExcel, data: { startDate: 2024-05-01, endDate: 2024-05-31 } }) wx.hideLoading() const { base64, count } res.result if (!base64) { wx.showToast({ title: 导出失败, icon: none }) return }我是建议把这段封装成一个独立的工具函数比如exportToExcel(params)。因为后面“写文件”和“分享”逻辑可能被多个页面复用比如订单页要导报表页可能也要导抽出来以后不用重复写。4.2 用 FileSystemManager 把文件写到本地拿到 base64 之后要先把字符串转成 ArrayBuffer再写入本地用户目录const fs wx.getFileSystemManager() const arrayBuffer wx.base64ToArrayBuffer(base64) const fileName 订单导出_${Date.now()}.xlsx const filePath ${wx.env.USER_DATA_PATH}/${fileName} fs.writeFileSync(filePath, arrayBuffer)这里有几个容易翻车的点第一文件扩展名一定要是.xlsx。有些同学随手写成.xls或.excel后面openDocument直接识别失败。第二文件名里避免使用特殊字符尤其是/和\否则写入路径会出问题。第三wx.env.USER_DATA_PATH是当前小程序的用户目录数据量大的文件建议导完后提示用户尽快转发别在这个目录里囤太多旧文件。4.3 直接预览和转发给微信好友文件写好后最简单的验证方式是直接打开预览wx.openDocument({ filePath, fileType: xlsx, showMenu: true, success() { console.log(打开成功) }, fail(err) { console.error(打开失败, err) } })showMenu: true很重要这样用户打开文件后右上角菜单里有“转发”和“保存到手机”等操作。如果不设置文件只能在预览界面干看着。如果业务上需要“直接把文件发给微信好友”可以用wx.shareFileMessagewx.shareFileMessage({ filePath, fileName: 订单导出.xlsx, success() { console.log(分享成功) } })这个 API 要求基础库版本较高低版本环境下建议做降级处理比如把文件路径展示出来让用户自己去文件管理器里找。做兼容时的判断很简单if (wx.shareFileMessage) { wx.shareFileMessage({ filePath, fileName: 订单导出.xlsx }) } else { wx.showModal({ title: 提示, content: 文件已生成请使用 ${filePath} 路径查看 }) }5. 我在实测中踩过的四个坑5.1 日期字段在 Excel 里显示成一串数字这是导出功能最经典的坑。第一次测试时我导出的表格里下单时间那一列显示的是类似1714521600000这样的数字运营看到直接懵了。原因很好解释日期被当成 number 写入单元格了。常见原因有两个一是cols配置里漏了type: date二是传给excel-export的值本来就是字符串date 类型解析失败后又走了默认逻辑。type: date同时还需要配合format字段比如yyyy-MM-dd hh:mm:ss。如果用的库版本格式串不生效我的建议是别纠结直接在查询阶段把日期格式化成字符串然后type用string。这样最稳const $ db.command.aggregate const res await db.collection(orders) .aggregate() .project({ orderNo: 1, customerName: 1, productName: 1, amount: 1, createTimeStr: $.dateToString({ format: %Y-%m-%d %H:%M:%S, date: $createTime }) }) .end()再提醒一句时区。云函数运行环境默认是 UTC 时区直接new Date(2024-05-01)拿到的可能是当天早上 8 点不是你预期的零点。做日期范围筛选时最好显式指定时区偏移或者在业务上约定所有时间都按北京时间处理。5.2 云函数返回内容过大导致调用失败base64 虽然有 1/3 的体积膨胀但真正的瓶颈不在膨胀本身而是云函数返回体的大小限制。我实测过当导出的数据量到五六万行时base64 字符串本身已经非常可观云函数调用经常直接失败小程序端报错还很抽象只说“云函数调用失败”。这里我的处理思路是两条路数据量可控几万行以内继续用 base64 直返但做好错误提示。数据量可能很大放弃 base64 直返改用云存储中转下面会讲。另外调用云函数时别忘了加上状态提示。导出时间超过三秒后用户就会开始焦虑我习惯用wx.showLoading配一句“正在生成 Excel数据量较大请稍候”导出完成后 toast 一个“导出成功共 N 条”。5.3 内存峰值和低配云函数的崩溃excel-export的模型是数据全部在内存里生成 XML 时也在内存里。看起来一个 xlsx 文件可能只有几 MB但生成过程中内存峰值可能是文件体积的好几倍。128MB 内存的云函数导出几万行时我遇到过实例被杀的情况日志里没有任何明确报错就是进程没了。所以我在控制台把云函数内存调到 256MB 以上导出量特别大的项目直接 512MB。这里不用心疼费用导出场景本身不是高频接口峰值资源占用几分钟就结束了。5.4 列宽无法自动适配中文表头容易折行excel-export没有“自动列宽”这个说法width是写死的。如果内容长度超出列宽Excel 里不会自动撑开而是显示成挤在一起的文本或者被截断。我的做法是在组装rows时顺手算一下每列最大字符数const colWidths new Array(cols.length).fill(10) rows.forEach(row { row.forEach((cell, index) { const len String(cell || ).length if (len colWidths[index]) { colWidths[index] len } }) }) cols.forEach((col, index) { col.width Math.min(colWidths[index] 2, 50) })中文和英文的宽度不一样这个估算不那么精确但至少能保证大部分内容完整显示。加 2 是为了给表头留点余量上限设 50 防止某一列出现超长文本把整个表格撑爆。6. 数据量变大之后的升级方案6.1 云存储中转用 fileID 代替 base64当数据量明显变大时直返 base64 的方案就该淘汰了。升级思路很直接云函数生成 xlsx 后直接上传到云存储把 fileID 返回给小程序端小程序再用downloadFile换取临时路径。云函数端改动很小const uploadRes await cloud.uploadFile({ cloudPath: exports/订单_${Date.now()}.xlsx, fileContent: buffer }) return { fileID: uploadRes.fileID, count: rows.length }小程序端变成const { fileID, count } res.result const downloadRes await wx.cloud.downloadFile({ fileID }) const { tempFilePath } downloadRes拿到tempFilePath之后后续的写文件、预览、分享流程跟前面完全一样。这个方案的好处是不再受云函数返回体大小的限制理论上导出数据量可以大很多。代价是生成的文件会占用云存储空间记得在云函数里定期清理历史导出文件不然时间一长会攒出一堆没人要的 xlsx。6.2 超大数据量的异步任务方案如果导出量真的到几十万行甚至百万行同步请求就不可能了——用户没耐心等一分钟云函数的超时时间也不够用。我在设计上会更进一步建一张“导出任务表”小程序端提交导出需求时生成一条任务记录云函数把任务丢给队列或定时触发器处理生成 xlsx 后上传云存储再把 fileID 回写到任务记录里。小程序端通过轮询或者订阅消息获知任务完成最后下载文件。这种方案实现成本高一些但用户体验最好。大多数中小业务其实用不到真到了这一步通常说明业务本身已经有规模了。6.3 别忘了做基础的入参校验导出的本质是“把数据库数据打包给别人”这里有两个潜在风险一是用户传进来的查询条件不合法二是导出范围超出用户权限。我在云函数入口处做了一个简单的白名单校验const allowedFields [orderNo, customerName, productName] if (Array.isArray(event.fields)) { event.fields.forEach(field { if (!allowedFields.includes(field)) { throw new Error(非法字段: ${field}) } }) }日期范围、页码这些参数也要做类型判断别直接把用户传的东西拼进数据库查询。权限这件事更要命如果某个页面本来只允许导出当前店铺的订单云函数里必须根据用户的身份信息做一次数据范围限制不能只靠小程序端按钮的显隐来控制。我在实际使用中的体会是导出功能“能用”很容易“好用”很难。文件命名、日期格式、列宽、内存和超时这些细节随便哪个没处理好用户拿到手的文件就不敢直接交给别人。最理想的状态是你导出的 Excel客户打开后不用改任何格式直接就能用。这不光靠excel-export的配置更靠你在组装数据时多想一步。最后再分享一个小技巧导出完成时把总数一起返回在小程序端展示成“导出成功共 10086 条”。用户核对数量的同时也顺便确认了这次导出的范围没选错省掉不少“数据是不是少了”的反复沟通。