通过JavaScript从流加载CSV文件时下载空文件且文件名异常排查
流式CSV报表下载异常:下载undefined.txt空文件的问题排查与修复
问题描述
我用Java后端通过StreamingResponseBody实现了流式生成CSV报表的接口,前端用fetch请求该接口,但点击下载按钮后会下载一个名为undefined.txt的空文件,调试发现问题出在fetch请求环节,请问哪里出错了?
后端核心代码
// 生成CSV的方法 public void generateIntervalReportCsv(Appendable fileOutputStream, String dateFrom, String dateTo, String searchType) throws Exception { try (Printer csvPrinter = new PrinterCsvAdapter(new CSVPrinter(fileOutputStream, CSVFormat.EXCEL.builder().setDelimiter(";") .setHeader("PERIOD", "STATUS", "RECORDS COUNT").build()))) { generateIntervalReport(csvPrinter, dateFrom, dateTo, searchType); } } // RestController接口 @GetMapping("/interval-report-file-csv") public ResponseEntity<StreamingResponseBody> generateReport(@RequestParam String dateFrom, @RequestParam String dateTo, @RequestParam String period, HttpServletResponse response) { response.setHeader( "Content-Disposition", "attachment;filename=My-Report.csv"); StreamingResponseBody stream = out -> { try { processService.generateIntervalReportCsv(new OutputStreamWriter(out), dateFrom, dateTo, period); } catch (Exception e) { throw new RuntimeException("Error - ", e); } }; return ResponseEntity.ok(stream); }
前端核心代码
function downloadCsvReport() { const minutesToAbort = 5; const awaiting_response_time_in_ms = 1000 * 60 * minutesToAbort; if (!is_downloadIntervalReport_alreadyPushed) { is_downloadIntervalReport_alreadyPushed = true; let dateFrom = document.getElementById("periodStartDate").value; let dateTo = document.getElementById("periodEndDate").value; let searchType = document.getElementById("period").value; let buttonStyle = document.getElementById("csvButtonId").style; buttonStyle.backgroundColor = "gray"; let url = "/interval-report-file-csv" + "?dateFrom=" + dateFrom + "&dateTo=" + dateTo + "&period=" + searchType; downloadIntervalReport_timeOfStartRequest = new Date(); fetch(url, {signal: AbortSignal.timeout(awaiting_response_time_in_ms)}) .then(async (res) => { if (res.status >= 200 && res.status < 300) { return res; } else { throw new Error(await res.text()); } }) .then(res => { res.blob().then(file => { let tempUrl = URL.createObjectURL(file); const aTag = document.createElement("a"); aTag.href = tempUrl; aTag.download = decodeURI(res.headers.get('Content-Disposition').split('UTF-8\'\'')[1]); document.body.appendChild(aTag); aTag.click(); URL.revokeObjectURL(tempUrl); aTag.remove(); }); }).catch((reason) => { if (reason.name === "AbortError") { console.log("Request aborted"); } else { console.error(reason.message); } }).finally(() => { is_downloadIntervalReport_alreadyPushed = false; buttonStyle.backgroundColor = "#d17b2e"; }); } }
错误原因分析
1. 后端问题
Content-Disposition头未遵循RFC标准的UTF-8编码格式,前端无法正确解析文件名- 未设置
Content-Type为text/csv,浏览器无法识别文件类型,默认使用txt后缀 - 直接通过
HttpServletResponse设置响应头,存在被Spring MVC覆盖的风险,不如通过ResponseEntity配置规范 OutputStreamWriter未指定编码,默认使用平台编码,可能导致内容乱码或空文件
2. 前端问题
- 文件名解析逻辑依赖
UTF-8''分割符,但后端未输出该格式的响应头,导致split结果为undefined,最终download属性为空 - 手动拼接URL参数存在编码问题,可能导致请求参数错误,后端返回空内容
- 嵌套的
res.blob().then写法易出现异步逻辑问题,且未处理默认文件名的 fallback
修复方案
后端修复代码
import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; // RestController接口优化 @GetMapping("/interval-report-file-csv") public ResponseEntity<StreamingResponseBody> generateReport(@RequestParam String dateFrom, @RequestParam String dateTo, @RequestParam String period) { // 遵循RFC标准配置UTF-8编码的文件名 String encodedFilename = URLEncoder.encode("My-Report.csv", StandardCharsets.UTF_8); String contentDisposition = String.format("attachment; filename*=UTF-8''%s", encodedFilename); StreamingResponseBody stream = out -> { try { // 指定UTF-8编码写入输出流,避免乱码 try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) { processService.generateIntervalReportCsv(writer, dateFrom, dateTo, period); } } catch (Exception e) { throw new RuntimeException("生成报表失败", e); } }; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8") .body(stream); }
前端修复代码
function downloadCsvReport() { const minutesToAbort = 5; const awaiting_response_time_in_ms = 1000 * 60 * minutesToAbort; if (!is_downloadIntervalReport_alreadyPushed) { is_downloadIntervalReport_alreadyPushed = true; const dateFrom = document.getElementById("periodStartDate").value; const dateTo = document.getElementById("periodEndDate").value; const searchType = document.getElementById("period").value; const buttonStyle = document.getElementById("csvButtonId").style; buttonStyle.backgroundColor = "gray"; // 使用URLSearchParams自动处理参数编码 const params = new URLSearchParams({ dateFrom, dateTo, period: searchType }); const url = `/interval-report-file-csv?${params}`; downloadIntervalReport_timeOfStartRequest = new Date(); fetch(url, {signal: AbortSignal.timeout(awaiting_response_time_in_ms)}) .then(async (res) => { if (!res.ok) { throw new Error(await res.text()); } return res; }) .then(async res => { const blob = await res.blob(); const tempUrl = URL.createObjectURL(blob); const aTag = document.createElement("a"); aTag.href = tempUrl; // 正确解析RFC标准的filename*格式响应头 const disposition = res.headers.get('Content-Disposition'); let filename = "My-Report.csv"; // 默认文件名 if (disposition) { const matches = disposition.match(/filename\*=UTF-8''(.*)/); if (matches && matches[1]) { filename = decodeURIComponent(matches[1]); } } aTag.download = filename; document.body.appendChild(aTag); aTag.click(); URL.revokeObjectURL(tempUrl); aTag.remove(); }) .catch((reason) => { if (reason.name === "AbortError") { console.log("请求超时被终止"); } else { console.error("下载失败:", reason.message); } }) .finally(() => { is_downloadIntervalReport_alreadyPushed = false; buttonStyle.backgroundColor = "#d17b2e"; }); } }
内容的提问来源于stack exchange,提问作者Evgeniy Zelenskiy
相关产品推荐
相关产品推荐

