ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

云函数+excel-export实现小程序Excel导出实战

云函数+excel-export实现小程序Excel导出实战

这个需求我接过不止一次了:运营要按天导出订单,客户要把报名名单拉出来给讲师看,管理员要把留言记录归档成 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 表格库有三个:xlsx(SheetJS)、node-xlsx、excel-export。我最终选excel-export,主要看中这几点:

对比项excel-exportxlsxnode-xlsx
生成格式标准 xlsx(内部走 zip 打包 XML)xlsx / csv 等xlsx
原生依赖无,安装即用无,但体积偏大基于 SheetJS
API 形态极简,配置列头和数据即可需要理解工作簿/工作表概念简洁,但样式控制少
维护状态老项目,多年没大更新社区版功能有限更新相对活跃

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数组的普通对象,不仅难看,还得自己重新组装。比较稳的做法是在云函数里转成 base64:

return { 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.shareFileMessage:

wx.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 条”。用户核对数量的同时,也顺便确认了这次导出的范围没选错,省掉不少“数据是不是少了”的反复沟通。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进