如何通过JS代码下载接口返回的文件并保留文件名

百科常识8小时前发布 jdkjadmin
0 00
如何通过JS代码下载接口返回的文件并保留文件名

如何通过JS代码下载接口返回的文件并保留文件名

调用下载接口时,文件内容通常以二进制响应返回,文件名则由响应头提供。浏览器不会自动把接口响应变成用户下载的文件:前端需要读取响应、创建可下载资源,并把文件名交给浏览器。若文件名含中文、空格或扩展名,还要正确处理服务端的 Content-Disposition 响应头。

本文从 Fetch API 的常见实现开始,逐步说明响应头解析、跨域限制、文件名安全处理和易错场景,并补充 Axios 的写法。示例面向浏览器端 JavaScript,不适用于 Node.js 服务端下载场景。

一、适用场景与处理流程

这种实现适用于用户点击按钮后,请求需要携带登录态、查询条件或其他请求头的下载接口。例如导出报表、下载合同、获取图片或压缩包。相比直接把接口 URL 放进链接,使用 JavaScript 请求可以控制请求方法、参数和身份凭据,也能检查 HTTP 状态码并读取响应头。

典型流程如下:

  1. 使用 Fetch 或 Axios 请求下载接口。
  2. 检查 HTTP 状态码,避免把错误提示当作文件保存。
  3. 将响应体读取为 Blob。
  4. 从 Content-Disposition 中取得文件名,并对其进行校验。
  5. 创建临时对象 URL,通过临时 <a> 元素触发下载。
  6. 下载触发后释放对象 URL。

关键点是:文件名和文件内容是两回事。Blob 保存响应体,响应头可能提供文件名;如果接口没有返回文件名,前端只能使用约定的默认名称或自行生成名称。

二、最小可运行示例

下面的函数使用 Fetch 发起请求,读取服务端文件名;响应头缺少文件名时使用调用方提供的默认值。把示例中的 URL 和请求参数替换为实际接口即可。

async function downloadFile(url, options = {}) {
  const {
    method = "GET",
    headers = {},
    body,
    credentials = "same-origin",
    fallbackName = "download"
  } = options;

  const response = await fetch(url, {
    method,
    headers,
    body,
    credentials
  });

  // fetch 遇到 404、500 等 HTTP 错误时通常仍会正常返回 Response,
  // 因此需要显式检查状态码。
  if (!response.ok) {
    throw new Error(`下载失败:HTTP ${response.status}`);
  }

  const blob = await response.blob();
  const disposition = response.headers.get("Content-Disposition");
  const filename = getSafeFilename(
    getFilenameFromDisposition(disposition) || fallbackName
  );

  const objectUrl = URL.createObjectURL(blob);
  const link = document.createElement("a");

  link.href = objectUrl;
  link.download = filename;
  link.style.display = "none";
  document.body.appendChild(link);
  link.click();
  link.remove();

  // 先触发下载,再延迟释放 URL,避免部分浏览器过早失效。
  setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}

downloadFile("/api/report/export", {
  method: "GET",
  fallbackName: "report.xlsx"
}).catch(error => {
  console.error(error);
  alert(error.message || "下载失败");
});

如果接口使用 POST,可以传入请求体:

downloadFile("/api/report/export", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    from: "2025-01-01",
    to: "2025-01-31"
  }),
  fallbackName: "report.xlsx"
}).catch(console.error);

注意,GET 请求通常不应发送请求体;是否使用 GET 或 POST,应由接口定义决定。若使用 JSON 请求体,服务端也必须支持对应的请求方法和内容类型。

三、从 Content-Disposition 读取文件名

常见响应头大致如下:

Content-Disposition: attachment; filename="report.xlsx"
Content-Disposition: attachment; filename*=UTF-8''%E6%8A%A5%E8%A1%A8.xlsx

attachment 表示响应内容适合作为附件处理。filename 是较早的文件名参数;filename* 使用扩展参数格式,能够表达 UTF-8 编码的非 ASCII 文件名,常用于中文名称。服务端可能同时发送两者,以兼容不同客户端;前端一般优先选择有效的 filename*,再退回到 filename。

下面的解析函数支持常见的 filename* 和带引号的 filename 格式。它不是完整的通用 HTTP 头语法解析器;对于非 UTF-8 编码、复杂转义或格式不规范的响应,应由服务端统一输出规范格式。

function getFilenameFromDisposition(header) {
  if (!header) return null;

  // RFC 5987 / 6266 常见形式:
  // filename*=UTF-8''%E6%8A%A5%E8%A1%A8.xlsx
  // filename*=UTF-8'en'%E6%8A%A5%E8%A1%A8.xlsx
  const extendedMatch = header.match(
    /filename\*\s*=\s*([^;]+)/i
  );

  if (extendedMatch) {
    let value = extendedMatch[1].trim();

    // 去掉可能存在的引号。
    if (value.startsWith('"') && value.endsWith('"')) {
      value = value.slice(1, -1);
    }

    const parts = value.match(/^([^']*)'[^']*'(.*)$/);
    if (parts) {
      const charset = parts[1].toLowerCase();
      const encodedName = parts[2];

      if (charset === "utf-8") {
        try {
          return decodeURIComponent(encodedName);
        } catch {
          // 编码格式不合法时继续尝试普通 filename。
        }
      }
    }
  }

  // 普通 filename,兼容带引号及引号内的反斜杠转义。
  const filenameMatch = header.match(
    /filename\s*=\s*("((?:\\.|[^"])*)"|[^;]*)/i
  );

  if (!filenameMatch) return null;

  let filename = filenameMatch[2] !== undefined
    ? filenameMatch[2].replace(/\\(.)/g, "$1")
    : filenameMatch[1].trim();

  // 对普通 filename 不应一概调用 decodeURIComponent:
  // 它可能包含字面上的百分号,未必是百分号编码。
  return filename || null;
}

上面的实现对 filename* 只接受 UTF-8。HTTP 扩展参数可以标注其他字符集,但现代 Web 服务通常应统一使用 UTF-8。若服务端返回其他字符集,不能把它当成 UTF-8 强行解码;应优先调整服务端响应头。

四、文件名必须按不可信输入处理

响应头来自服务端,并不意味着其中的文件名可以直接信任。文件名可能含有路径分隔符、控制字符或操作系统不适用的字符。浏览器会对 download 属性做一定处理,但不应把浏览器的处理当成完整的安全校验,也不应把服务器提供的文件名用于服务端文件路径。

在前端下载场景中,可以先把路径分隔符替换为下划线,再移除控制字符和常见不适用字符,并处理空名称、特殊名称及尾部空格或句点:

function getSafeFilename(value) {
  let name = String(value || "download");

  // 去掉可能伪装成路径的目录部分。
  name = name.replace(/\\/g, "/").split("/").pop();

  // 移除控制字符,替换常见文件系统不适用字符。
  name = name
    .replace(/[\u0000-\u001F\u007F]/g, "")
    .replace(/[<>:"|?*]/g, "_")
    .trim()
    .replace(/[. ]+$/g, "");

  if (!name || name === "." || name === "..") {
    name = "download";
  }

  return name;
}

这段处理主要用于浏览器端展示和下载命名,不是防止恶意文件内容的安全机制,也不是所有操作系统文件名规则的完整实现。不要因为扩展名看起来安全,就信任文件内容;如果应用需要判断文件类型或限制上传、存储行为,应在服务端按实际数据和业务规则校验。

五、跨域接口需要服务端配合暴露响应头

同源接口中,脚本通常可以读取 Content-Disposition。跨域请求则受浏览器 CORS 规则约束:即使响应中实际包含该头,前端 JavaScript 也可能读不到它。服务端需要把它加入 Access-Control-Expose-Headers:

Access-Control-Expose-Headers: Content-Disposition

若同时需要带跨域 Cookie,服务端还必须按凭据请求配置 CORS,明确允许请求来源,并允许凭据;不能在允许凭据时把允许来源设为通配符 *。前端对应设置为:

fetch("https://files.example.com/api/export", {
  credentials: "include"
});

credentials 常见取值是 same-origin(默认值)和 include。前者在同源请求中发送凭据,后者允许跨域请求携带凭据,但能否成功仍取决于服务端 CORS 配置及浏览器的 Cookie 策略。带凭据跨站请求还可能受到 Cookie 的 SameSite 属性和浏览器隐私限制影响。

跨域环境下,若读取到的 Content-Disposition 为 null,先检查浏览器开发者工具中的响应头和 CORS 配置。仅在前端增加代码,无法绕过浏览器的 CORS 限制。

六、什么时候不必使用 Blob 方式

如果下载接口不需要前端自定义请求头或请求体,也不需要在 JavaScript 中检查响应,可以直接使用链接,让浏览器处理导航和下载:

<a href="/api/files/123">下载文件</a>

也可以由服务器在响应头中设置 Content-Disposition。这种方式通常不需要把整个文件读入 JavaScript 内存,更适合大文件。但它不方便添加自定义授权请求头,也不便于在前端读取响应内容。链接的 download 属性对跨源 URL 的行为有限制,且浏览器可能根据响应头、文件类型和安全策略决定实际行为;需要可靠的文件名时,优先正确设置服务端响应头。

使用 Fetch 后调用 response.blob(),浏览器需要把响应体作为 Blob 供脚本使用。对于体积很大的文件,这可能增加内存占用,具体表现与浏览器实现有关。若文件很大,或需要可靠的断点续传、进度控制和流式写入,应考虑由浏览器直接导航到下载地址,或采用支持流式保存的专用方案,而不是默认把完整响应读成 Blob。

七、Axios 写法

使用 Axios 时,浏览器端请求需要指定 responseType: "blob",否则响应可能按文本或 JSON 处理。文件名仍从响应头读取;Axios 通常会把响应头键名转换为小写:

async function downloadWithAxios(axios, url) {
  const response = await axios.get(url, {
    responseType: "blob",
    withCredentials: false
  });

  const disposition =
    response.headers["content-disposition"] || null;

  const filename = getSafeFilename(
    getFilenameFromDisposition(disposition) || "download"
  );

  const objectUrl = URL.createObjectURL(response.data);
  const link = document.createElement("a");

  link.href = objectUrl;
  link.download = filename;
  link.style.display = "none";
  document.body.appendChild(link);
  link.click();
  link.remove();

  setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}

如果跨域请求需要 Cookie,可以将 withCredentials 设为 true,同时配置服务端 CORS。示例中的 false 仅表示不主动要求跨域凭据;同源认证、拦截器或其他 Axios 配置仍可能影响实际请求。

Axios 的 HTTP 错误通常会进入拒绝分支,但如果服务端用成功状态码返回了 JSON 错误内容,且响应类型指定为 Blob,错误信息也可能成为一个 Blob。遇到这种接口约定时,需要检查响应类型或读取 Blob 文本后再判断,不能仅凭“拿到了 Blob”就认定下载成功。

八、常见错误与排查方法

1. 下载下来的是 JSON 或错误页面

检查响应状态码、响应体和服务端日志。Fetch 不会因为 HTTP 404 或 500 自动抛出异常,因此必须检查 response.ok。还要确认接口在成功时返回的是文件内容,而不是一个包含文件地址的 JSON;如果返回 JSON,应先按接口约定解析并请求实际文件。

2. 文件名始终是默认值

检查响应头是否确实包含 Content-Disposition。跨域时还要确认响应中有 Access-Control-Expose-Headers: Content-Disposition。此外,服务端可能返回的是不同大小写的头名称;使用 Fetch 的 headers.get("Content-Disposition") 不需要区分大小写,Axios 则通常从小写键名读取。

3. 中文文件名乱码

检查服务端是否提供了格式正确的 UTF-8 filename* 参数,以及其中的百分号编码是否合法。不要对普通 filename 无条件调用 decodeURIComponent,否则合法的百分号字符可能引发解码异常或被错误解释。

4. 点击后没有弹出下载

确认代码运行在用户操作触发的流程中。浏览器可能限制非用户触发的下载;若先等待很久再创建并点击链接,也可能不符合浏览器对用户激活的要求。检查 JavaScript 异常、对象 URL 是否有效,以及页面是否在点击后立刻跳转或卸载。

5. 文件内容为空或无法打开

确认服务端实际返回了文件字节,而不是空响应、登录跳转页面或错误内容。还要检查请求方法、参数、Cookie 和授权头是否符合接口要求。对于返回的压缩包、表格等文件,扩展名与实际内容不匹配也可能导致系统或应用无法识别。

6. 文件名带有奇怪字符或被截断

检查服务端的响应头格式和前端解析逻辑。分号、引号、反斜杠等字符会影响简单的正则解析;如果服务端生成的头字段格式复杂,优先修正服务端输出,保持 UTF-8 filename* 与兼容用的普通 filename 规范一致。不要试图用一个未经验证的短正则覆盖所有可能的 HTTP 头格式。

九、推荐实践

  • 优先让服务端使用 Content-Disposition: attachment,并提供规范的 UTF-8 filename*;必要时再提供兼容用的 filename。
  • Fetch 请求后检查 response.ok,不要把错误响应直接保存成文件。
  • 仅在确实需要前端控制请求时使用 Blob 下载;大文件应评估内存开销。
  • 把响应头文件名视为不可信输入,使用安全处理后的名称,并提供合理默认值。
  • 跨域时分别检查 CORS 是否允许请求,以及脚本是否被允许读取 Content-Disposition;它们是两个独立问题。
  • 下载完成后调用 URL.revokeObjectURL,及时释放对象 URL 关联的资源。

归纳起来,保留服务端文件名的核心并不复杂:读取 Content-Disposition,优先解析 filename*,将响应体转换为 Blob,再通过临时链接触发下载。真正容易出问题的地方是跨域响应头暴露、错误响应判断、中文编码和不可信文件名处理。把这些边界纳入实现,下载逻辑才更可靠。

© 版权声明

相关文章