每年总有那么几次,需求方拎着一份表格过来说:“这个页面加个导出功能。”听起来挺简单,真正动手用 Vue 实现导出功能之后才会发现,文件格式、数据来源、接口协议、浏览器兼容、中文编码,每个环节都有意想不到的细节。这篇文章想把我这两年做导出功能的思路完整梳理一遍,从最基础的前端本地生成 CSV、Excel,到对接后端文件流下载,再到上线后才会遇到的超时、乱码、重复下载问题,尽量把能直接抄的代码和真正值得避开的坑都放出来。
如果你接手的是一个后台管理系统、报表平台、数据中台,或者是任何带“导出按钮”的 Vue 项目,这篇文章的实操部分应该能直接帮到你。即便你只有几周 Vue 基础,只要能把ref、async/await、axios用明白,跟着代码走一遍也能把这套逻辑搬到自己的项目里。
1. 动手之前先把导出需求盘清楚
很多人在写导出代码前,第一反应是上网搜一个“一键导出”的库,装上照样写两行就完了。但我在实际项目里吃过亏之后,现在接到导出需求的第一件事,永远是问三个问题:导成什么格式、数据从哪里来、大概有多少行。这三个问题不搞清楚,代码怎么写都是错。
1.1 第一个问题:文件格式由谁定
导出格式看着是个小决定,实际上直接决定技术路线。如果业务方说要“Excel”,你得再确认一句:是必须.xlsx带格式多工作表,还是只要能用 Excel 打开的.csv就行?很多情况下“Excel”指的就是后者,而 CSV 方案要轻量得多,前面加个 BOM 头,中文不乱码,双击就能打开,数据量几千行毫无压力。
如果明确要.xlsx,那就意味着格式上需要支持多 sheet、列宽、合并单元格、字体颜色这些东西,这时候前端用xlsx社区版会非常吃力,因为它对样式支持很有限。更常见的情况是让后端生成真正的 Excel,前端只负责把流接住,丢给浏览器下载。至于.pdf,那是打印和单据类的需求,方案又会变成 html2canvas 拼接或者 jspdf,跟普通数据导出走的是完全不同的路,不在这次讨论范围内。
1.2 第二个问题:数据到底在哪里
这是导出功能最容易被忽略的坑。我遇到过不止一次,“导出”按钮做完了,业务方点完发现导出来只有当前这一页 20 条,然后回来找我:“数据不全。”原因很简单,前端表格用的是分页接口,代码直接拿表格当前页的dataSource去生成了文件。数据本身在接口里还有一万条,前端根本没拿到。
所以做之前一定要判断数据位置:
- 数据已经全量存在于前端,比如 Vuex/Pinia 里的某个状态、已经拉回来的一次性字典表,此时完全可以在本地生成文件,不需要额外接口;
- 数据只存在后端数据库,前端只拿到分页后的当前页,这时候必须走后端导出,前端负责请求和下载;
- 前端能拿到全部列表数据,但总量上万,此时虽然技术上能本地导出,也要考虑性能,建议评估后再决定是前端生成还是后端生成。
这个判断做完,后面选方案才顺。不然代码写一半发现“前端哪来全量数据”,整个返工。
1.3 第三个问题:数据量是什么量级
量级决定了方案的最终形态。几百行、几千行的 CSV,前端瞬间出文件,体验很好。到了几万行,前端本地拼 JSON、再转 sheet、再写文件,虽然不至于崩溃,但页面已经能感觉到明显卡顿,尤其在低端电脑上,内存占用也容易飙高。再往上到几十万行,前端生成.xlsx基本不可取,因为 Excel 单 sheet 上限是 1048576 行,加上内存开销,浏览器撑不住,这时候必须让后端生成文件并提供异步任务接口,前端用轮询的方式等待结果。
我见过一个比较极端的例子,某后台的流水导出,一天的数据就有十几万行,一开始图省事用前端xlsx直接拼,结果用户一导出页面直接卡死,最后只能老老实实做了“创建导出任务—后台生成—前端轮询拉取”的异步导出流程。所以量级不是后面优化时再想的问题,而是动手前就必须有数。
2. 前端本地生成 CSV:最轻量的导出方案
如果数据已经在手头,格式也不要求花里胡哨,用 CSV 是最快的。CSV 本质是一个逗号分隔的文本文件,任何操作系统、任何编辑器都能打开,不需要额外的解析库,更不需要 Excel 环境。它的缺点同样明显:不能保存单元格样式、一个文件只有一张表、如果字段里带着逗号换行必须做转义。但这些只要提前想清楚,CSV 完全能覆盖大多数“基础数据导出”的场景。
2.1 原理:Blob、Blob URL 和 a 标签下载
先把原理剥开。要让浏览器下载一个文件,本质是让浏览器创建一个“可下载地址”,然后模拟点击一个带download属性的a标签。Blob负责把字符串或字节流打包成一个文件对象,URL.createObjectURL(blob)会生成一个临时地址,a.download指定下载文件名,最后a.click()触发下载,URL.revokeObjectURL释放临时地址。这套逻辑是前端文件导出的底层通用机制,不管 CSV 还是 Excel、PDF,最后几步都一样。
很多同学会直接用原生Blob写,也推荐使用file-saver这个很小的工具库,它把上面的底层操作封装成了saveAs(blob, filename),兼容性处理得更好。我下面的示例就基于它。
2.2 中文乱码和 BOM 的关系
CSV 最常见的翻车现场是:导出之后用 Excel 打开,中文全是乱码。原因不复杂:Excel 在 Windows 环境下默认按 ANSI 编码读 CSV,而前端生成的字符串是 UTF-8,两边对不上。解决办法是在整个文本的最前面塞一个 BOM 头\uFEFF,Excel 看到 BOM 就会按 UTF-8 解析,乱码问题消失。
另外一个绕不开的坑是字段转义。CSV 不是简单的“用逗号把值拼起来”,如果某一格是"你好, 世界"或者带换行,不处理的话列就错位了。正确做法是:字段里只要出现逗号、换行、双引号,就把整个字段用双引号包起来,内部的双引号再翻倍转义。
下面这个函数可以直接抄走:
import { saveAs } from 'file-saver'; /** * 将二维数组导出为 CSV * @param filename 文件名,建议带 .csv 后缀 * @param headers 表头数组 * @param rows 数据行二维数组 */ export function exportCsv(filename: string, headers: string[], rows: unknown[][]) { // 拼接表头 const headerLine = headers.map(escapeCsvCell).join(','); // 拼接每一行 const rowLines = rows.map((row) => row.map((cell) => escapeCsvCell(cell)).join(',') ); // \uFEFF 是 BOM 头,专门解决 UTF-8 中文在 Excel 中的乱码 const csvContent = '\uFEFF' + [headerLine, ...rowLines].join('\r\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); saveAs(blob, filename); } function escapeCsvCell(cell: unknown): string { if (cell === null || cell === undefined) { return ''; } const str = String(cell); // 含有逗号、双引号、换行时需要特殊转义 if (/[",\n\r]/.test(str)) { return `"${str.replace(/"/g, '""')}"`; } return str; }在 Vue 组件里调用的地方很简单:
<script setup lang="ts"> import { exportCsv } from '@/utils/export'; import { ref } from 'vue'; const list = ref([ { name: '张三', dept: '研发部', joinDate: '2024-03-15' }, { name: '李四', dept: '运营部, 用户组', joinDate: '2025-01-10' }, ]); const headers = ['姓名', '部门', '入职时间']; function onExport() { const rows = list.value.map((item) => [ item.name, item.dept, // 注意这里带了逗号,escapeCsvCell 会处理好 item.joinDate, ]); exportCsv('员工名单.csv', headers, rows); } </script>实测下来这套方案对几千行数据非常稳。另外一个细节是换行符建议用\r\n,虽然现代 Excel 对\n已经足够容忍,但对一些老版本和第三方编辑器,\r\n是兼容性最稳的选择。
3. 用 Excel 相关库导出真正的 xlsx 文件
CSV 解决不了的问题,比如一个工作簿里要放多个工作表、合并单元格、控制列宽、数值类型要精准,就需要用专门处理 Excel 的库。前端生态里最主流的还是xlsx(SheetJS 社区版)和exceljs这两个,我分开说一下怎么选。
3.1 xlsx 和 exceljs 怎么选
xlsx的优势在于 API 极其简单,把数组或 JSON 塞进去,一个writeFile就把文件给到浏览器,特别适合“把表格数据倒成文件”这种标准动作。但它社区版在样式方面几乎等于没有,想设置单元格背景色、边框、字体加粗,它干不了,而且它本身不依赖浏览器环境,在 Node 里也能跑,很容易被后端同事拿去生成文件。
exceljs能操作单元格、行高列宽、边框、背景色、公式,甚至能往单元格里塞图片,功能强非常多。代价是打包体积明显更大,API 更琐碎,同样的“数据转表格”操作要写好几行。所以我的建议是:只要纯数据导出,用xlsx;一旦业务方明确要求“哪个列要标黄、表头要加粗、列宽多少”,直接用exceljs,别硬拿xlsx魔改。
3.2 数据转工作表的核心操作
xlsx最常用的转换函数是aoa_to_sheet和json_to_sheet。前者接收二维数组,适合按指定列顺序输出;后者接收 JSON 对象数组,适合数据结构本来就是对象数组的场景。我一般优先用aoa_to_sheet,因为json_to_sheet是按对象 key 的枚举顺序排列字段的,实际项目中往往需要严格控制列顺序,用数组更直观。
一个支持多工作表的导出函数可以这样封装:
import * as XLSX from 'xlsx'; interface ExcelSheetData { name: string; headers: string[]; rows: unknown[][]; } export function exportExcel(filename: string, sheets: ExcelSheetData[]) { const workbook = XLSX.utils.book_new(); sheets.forEach((sheet) => { const aoa = [sheet.headers, ...sheet.rows]; const worksheet = XLSX.utils.aoa_to_sheet(aoa); // 可选的列宽设置,单位是字符宽度,不是像素 worksheet['!cols'] = sheet.headers.map((header) => ({ wch: Math.max(header.length * 2, 15), })); XLSX.utils.book_append_sheet(workbook, worksheet, sheet.name); }); XLSX.writeFile(workbook, filename); }工作表的名称有几个隐藏规则容易踩坑:不能超过 31 个字符、不能为空、同一个 workbook 里不能重名,也不能包含[]:*?/\\这些字符。取名字的时候尽量用业务名称,比如员工名单和工资汇总,一旦用户传进来的 sheet 名称超长,导出时库会直接报错。
3.3 类型问题:数字精度和日期序列值
用xlsx导出最容易踩的专业坑,是数字类型和日期类型。
第一个是超长数字精度问题。身份证号、订单号这类超过 15 位的数字,如果以 number 类型写入单元格,Excel 会把它转成科学计数法,而且第 16 位之后直接被抹成 0。解决办法是在写数据之前把这些字段转成字符串。因为aoa_to_sheet遇到字符串就会按文本类型写入,文本在 Excel 里不会被精度截断。当然,如果你的业务后续要用这些数值做公式计算,那得另说。
第二个是日期显示成“45329”这种序列值。Excel 内部日期本质是一个数字,aoa_to_sheet不会自动把 JS 的Date对象识别成日期格式,写入后经常显示为序列号。最稳妥的解决方案是在导出前把所有日期格式化成字符串,比如dayjs(date).format('YYYY-MM-DD HH:mm:ss'),让用户看到的就是字符串文本。虽然这张表里没了“真日期”可以继续做日期筛选,但对大多数导出场景,可读性比可计算性重要得多。
// 示例:把对象数组映射成二维数组时,顺手做好类型转换 const rows = list.value.map((item) => [ item.userId.toString(), // 有 18 位数字的 ID 必须转字符串 item.name, formatDate(item.createTime), // 日期格式化成字符串 Number(item.amount.toFixed(2)), // 金额保留两位小数 ]);社区版xlsx对单元格样式基本不支持,所以如果你看到的导出模板需求里有“标题合并居中”“表头背景变色”“某一列超过多少标红”,还是早点决定换exceljs,或者在方案评审阶段说服业务方把这些样式需求砍掉,否则前端实现成本会直线上升。
4. 对接后端文件流:请求、响应头、落盘
本地生成方案有天然边界:数据不在前端、数据量太大、权限校验要在后端做,这些情况下导出必须要走后端。前端在这一环扮演的角色,是把后端的文件流接住,判断到底是文件还是错误信息,然后正确触发浏览器下载。听起来简单,实际做起来细节很多。
4.1 为什么大导出优先让后端生成
后端生成的不可替代性有两个:一是数据全,后端可以直接查全量库表,不受前端分页限制;二是安全,导出往往意味着数据离开系统,后端可以对导出动作做权限控制、操作日志、次数限制。我遇到一个比较典型的场景是财务导出对账单,用户选择的日期范围跨三个月,记录有五六万行,前端就算拉下来了,在浏览器里转 Excel 也会非常吃力。换成后端生成后,前端只负责发一个创建任务的请求,剩下就是等待。
所以技术选型时我的原则是:1 万行以内前端本地能处理的不折腾后端;超过这个量,或格式复杂、权限敏感,直接走上后端方案,前端少背锅。
4.2 axios 请求必须显式声明 responseType
对接后端文件流时,最常出现的问题是接口返回之后,前端拿到的数据是乱码或者内存占用巨大的字符串。原因通常是 axios 默认把响应当成 JSON 解析,而后端实际上返回的是二进制流。所以请求导出接口时,一定要在参数里显式声明responseType: 'blob'。
import axios from 'axios'; const http = axios.create({ baseURL: '/api', timeout: 30000 });一个常规的文件下载请求可以按下面的模式写:
export async function downloadFile(url: string, params: Record<string, unknown>) { const response = await http.get(url, { params, responseType: 'blob', }); // 即使声明了 blob,某些后端在出错时仍返回 JSON 数据,需要单独处理 if (response.data.type === 'application/json') { const reader = new FileReader(); const errorText = await new Promise<string>((resolve) => { reader.onload = () => resolve(reader.result as string); reader.readAsText(response.data); }); const errorJson = JSON.parse(errorText); // 在这里提示 errorJson.message return; } const filename = getFilenameFromHeaders(response.headers['content-disposition']); const blobUrl = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = blobUrl; link.download = filename || '导出文件.xlsx'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); }强调三点。第一,responseType: 'blob'不仅get要设,如果是post下载,也同样要传一个包含responseType的 config,不要只传data就完事。第二,后端靠 HTTP status code 表达成功失败不靠谱,很多导出失败响应编码是一个 200,但 body 里却是application/json的错误对象,所以必须判断response.data.type。第三,下载完成后要调用URL.revokeObjectURL,否则长时间高频导出会让浏览器内存上涨。
4.3 Content-Disposition 文件名解析
后端返回文件时会把文件名放在响应头Content-Disposition里,常见格式是attachment; filename="export.xlsx",如果是中文文件名,规范做法是再加一个filename*=UTF-8''%E5%AF%BC%E5%87%BA.xlsx。浏览器拿到之后,前端要自己解析这个头。
解析时我见过各种写法,下面是比较稳的一版:
function getFilenameFromHeaders(contentDisposition: string | undefined): string { if (!contentDisposition) { return ''; } // 优先取 filename*=UTF-8'' 这种带编码的文件名 const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); if (encodedMatch && encodedMatch[1]) { try { return decodeURIComponent(encodedMatch[1]); } catch { // 解码失败时直接返回原值,让后续用默认文件名兜底 return encodedMatch[1]; } } // 退一步取 filename="xxx" 这种普通形式 const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/); if (plainMatch && plainMatch[1]) { return plainMatch[1]; } return ''; }这个函数看起来简单,但没有的话很容易遇到“下载下来的文件名变成一串随机字符”的问题。有些后端不会设置filename*,只给一个filename="2025-1-12.xlsx",这种情况下也必须兜住。另外,decodeURIComponent一定要包 try/catch,一旦文件名格式不规范,解码会直接抛错,导致整个下载流程崩掉。
4.4 后端返回的不是文件而是 JSON 时的处理
这一步特别容易被忽略。正常情况下导出接口成功返回文件,但如果用户选择的日期范围内没有数据,后端返回的可能是一个业务错误 JSON。前端如果不做判断,会把一个application/json的 Blob 当成文件下载下来,用户打开文件一看是一段乱码或者一坨 JSON。所以上面示例里response.data.type === 'application/json'的判断是非常关键的防护。
这个判断也适用于 HTTP 401、403 这类鉴权失败场景,因为后端网关返回的常常是一个 JSON 错误页。处理方式是先用FileReader把 Blob 读成文本,再JSON.parse拿错误信息,然后提示给用户。注意一个细节:response.data.type读到的 MIME 可能是application/json;charset=UTF-8,所以判断时用includes('application/json')而不是===,更稳妥。
5. 上线之后才暴露的导出问题与定位过程
代码写完、功能上线,才是导出真正接受考验的开始。很多问题在开发环境怎么测都测不出来,一放到真实用户环境就冒出来。这一节我按自己排查过的顺序,把最典型的几个问题和定位链路写清楚。
5.1 文件名中文乱码与下载名称丢失
现象是用户下载后,文件名要么是一大串%E5%AF%BC%E5%87%BA%E6%96%87%E4%BB%B6.xlsx,要么直接是download,完全没有业务含义。排查时先看响应头Content-Disposition的实际内容,确认是后端没设置头,还是设置了前端没解析对。
如果是后端返回filename=导出文件.xlsx,这种不带编码的中文在 HTTP 头里本身就可能被网关改写,所以后端同事需要改成同时返回filename="export.xlsx"和filename*=UTF-8''%E5%AF%BC%E5%87%BA%E6%96%87%E4%BB%B6.xlsx这种组合。前端这边,不管后端怎么做,都要在 getFilenameFromHeaders 后面加一个兜底默认文件名。我的习惯是前端固定传一个默认名,比如导出数据_20250112.xlsx,这样即使解析失败,用户拿到的文件也能识别。
5.2 导出任务超时:同步接口直接废掉
功能上线第一次大范围使用,反馈就来了:“点了导出,转圈转了一分钟,最后提示超时。”定位发现后端生成文件需要 30 秒,而 axios 默认没有超时限制,真正卡住的是 Nginx 或者后端网关的代理超时。就算把前端 timeout 调大,也没办法解决网关在中间把连接断掉的问题。
正确解法是把“同步导出”改成“异步导出任务”。前端先调用创建任务接口,拿到一个taskId,然后定时轮询任务状态,等任务变成success之后,再调下载接口拿文件。这个模式看着多写了一些代码,但它是数据量大时唯一稳定的方案。下面是一个可参考的最小实现:
export async function asyncExport(fileName: string, queryParams: Record<string, unknown>) { // 1. 创建导出任务 const taskRes = await http.post('/export/task', queryParams); const taskId = taskRes.data.data.taskId; // 2. 轮询任务状态,最多等 120 秒 for (let i = 0; i < 40; i++) { await sleep(3000); const statusRes = await http.get(`/export/task/${taskId}`); const status = statusRes.data.data.status; if (status === 'success') { await downloadFile(`/export/task/${taskId}/file`, {}); return; } if (status === 'failed') { // 提示任务失败,并展示失败原因 return; } } // 3. 超时后给出友好提示 alert('导出任务仍在处理中,请稍后到通知中心查看'); } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }异步导出还需要产品上的配合,比如导出记录列表、下载中心、失败重试按钮。一旦用户量上来,这是不可避免的演进方向。
5.3 重复点击导致多次下载
这个属于交互层面的低级错误,但线上出现频率特别高。用户连点两下导出按钮,浏览器下载了两个同名文件,后端同时跑了两遍导出任务。我排查下来的根因基本一致:组件里没有对导出中的状态做任何限制。修正方法很简单,用一个ref作为锁,导出开始前判断锁是否已被占用,请求完成后在finally里释放锁。
有一个容易被遗漏的细节是:如果导出流程中已经弹出了错误提示,也必须在finally里把exporting重置为false,否则第一次点击失败之后,后续点击全被锁死,用户会以为功能坏了。这个 bug 我在开发环境没测出来,是从“点一次失败后第二次就没反应”的用户反馈里倒推出来的。
5.4 下载下来的文件打不开
这个问题的排查链路比较长,可能的原因有三个:文件本身生成坏了、传输过程中被截断了、前端误把 JSON 当文件下载了。排查顺序是:先用浏览器直接访问后端下载接口,看看文件能不能打开;然后看下载完成后本地文件的大小和响应头Content-Length是否一致;最后再用代码判断response.data的 MIME 类型。多数情况下,用户反馈“打不开”时,我先让他用记事本打开文件看一眼内容——如果看着是 JSON,那就是错误流没拦截住;如果是乱码二进制,才是网络传输或者文件生成的问题。
6. 沉淀一个可复用的导出模块
导出功能最大的特点是“页面不同,逻辑相同”。在一个后台系统里,可能十几个页面都有导出按钮,但核心代码就那么几段。与其每次复制粘贴,不如把方案抽成一个统一模块。我通常会建一个utils/export.ts,对外暴露几个高内聚的方法,然后在组件里只负责传数据和显示 loading。
6.1 统一入口:按类型分发
我的导出模块思路很简单,对外暴露三种能力:本地 CSV、本地 Excel、后端文件流下载。各方法内部自己做 Blob 处理、文件名解析、错误拦截。组件里只需要根据业务场景调用对应方法,不需要关心浏览器下载底层是什么。
下面是一个简化的入口封装,按我的项目习惯,还会再加一个downloadByStream,专门处理后端流式下载。
export enum ExportType { CSV = 'csv', EXCEL = 'excel', STREAM = 'stream', } export interface ExportOptions { type: ExportType; fileName: string; // CSV / Excel 用 headers?: string[]; rows?: unknown[][]; // 多 sheet 用 sheets?: Array<{ name: string; headers: string[]; rows: unknown[][] }>; // 后端流用 url?: string; params?: Record<string, unknown>; } export async function exportData(options: ExportOptions) { const { type, fileName, headers, rows, sheets, url, params } = options; if (type === ExportType.CSV) { if (!headers || !rows) { throw new Error('CSV 导出需要 headers 和 rows'); } exportCsv(fileName, headers, rows); return; } if (type === ExportType.EXCEL) { if (sheets) { exportExcel(fileName, sheets); } else if (headers && rows) { exportExcel(fileName, [{ name: 'Sheet1', headers, rows }]); } return; } if (type === ExportType.STREAM) { if (!url) { throw new Error('后端流导出需要 url'); } await downloadFile(url, params || {}); } }封装的价值不在于省几行代码,而在于把容易出错的环节集中处理。比如后来公司要求所有导出都要带操作日志,我就在这个统一入口里加了一个日志上报的调用,所有页面立刻生效,不用逐页去改。这就是统一抽象的实际收益。
6.2 在 Vue 组件里的落地写法
组件侧配合useExport这类组合式函数,把 loading 和并发控制收敛起来。下面这个示例使用 Vue 3 组合式 API,Vue 2 的 options API 只要把ref换成data、把function放进methods,逻辑完全一致。
// src/composables/useExport.ts import { ref } from 'vue'; import { exportData, ExportType } from '@/utils/export'; export function useExport() { const exporting = ref(false); async function runExport(task: () => Promise<void>) { if (exporting.value) { return; } exporting.value = true; try { await task(); } finally { exporting.value = false; } } return { exporting, runExport, }; }组件里的用法:
<script setup lang="ts"> import { exportData, ExportType } from '@/utils/export'; import { useExport } from '@/composables/useExport'; const { exporting, runExport } = useExport(); const list = ref([]); function onExport() { runExport(async () => { const headers = ['姓名', '部门', '入职时间']; const rows = list.value.map((item) => [ item.name, item.department, item.joinDate, ]); await exportData({ type: ExportType.CSV, fileName: `员工信息_${Date.now()}.csv`, headers, rows, }); }); } </script> <template> <button :disabled="exporting" @click="onExport"> {{ exporting ? '导出中...' : '导出' }} </button> </template>这样把交互锁和错误处理都交给useExport,页面上只需要关心“我要导什么”,不需要关心“导出内部怎么防重复点击”。将来如果改成异步任务模式,也只需要替换runExport内部调用的方法,页面代码可以完全不动。
6.3 还有哪些扩展方向可以做
导出模块做到这步,已经能覆盖大多数系统了,但要继续做得更好,有几个方向可以作为扩展:给每一份导出文件加时间戳,避免同名覆盖;把导出参数存一份到 URL 或者操作记录里,方便后端排查问题;针对大表格做分片导出,前端每拉一页就追加一页数据,最后统一生成文件;或者把导出按钮的权限控制收到统一指令里,按按钮级别控制谁能导、谁能导全量。还有更贴近业务的,比如导出失败时通知管理员、导出成功后自动发邮件附件,这些是异步任务模式延伸出来的能力,已经超出前端范畴,需要后端同事一起配合。
我个人在实际项目里体会最深的一点是,导出功能看着不起眼,但它非常考验一个前端对数据流和异常链路的完整认知。任何一环想当然,用户拿到手的都是打不开的文件、乱码的表格,或者干脆就是半个小时的空白等待。如果你现在正要给项目加导出功能,建议先别急着写代码,回到文章开头那三个问题——格式、数据源、量级,把这张图理清楚,你的导出功能就已经成功了一半。