You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS端Firefox文件下载显示未知文件名与错误文件大小问题

iOS Firefox下Node.js+React文件下载异常问题排查与解决

问题现象

基于Node.js + Express后端与React前端开发的文件下载功能,在多数浏览器中运行正常,但iOS端Firefox出现以下异常:

  • 文件名显示为「Unknown」而非预期名称;
  • 下载开始前文件大小显示正确,但下载后的文件远小于预期(如应几MB却仅187KB),且文件内容为空。

后端代码(Node.js + Express)

使用fs.createReadStream流式传输文件至响应:

const fs = require('fs');
const path = require('path');

exports.downloadFile = (req, res, next) => {
    try {
        console.log('Download endpoint hit');

        const filepath = req.body.filepath;
        const filename = path.basename(filepath);
        const encodedFilename = encodeURIComponent(filename).replace(/%20/g, ' ');

        let stat;
        try {
            stat = fs.statSync(filepath);
        } catch (err) {
            console.error('File not found:', filepath);
            return res.status(404).json({ success: false, msg: 'File not found' });
        }

        res.status(200);
        res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`);
        res.setHeader('Content-Type', 'application/octet-stream');
        res.setHeader('Accept-Ranges', 'bytes');
        res.setHeader('Content-Length', stat.size);
        res.flushHeaders();

        const fileStream = fs.createReadStream(filepath);

        fileStream.pipe(res);

        fileStream.on('error', (err) => {
            console.error('File stream error:', err);
            if (!res.headersSent) {
                res.status(500).json({ success: false, msg: 'File download failed' });
            }
        });

        fileStream.on('end', () => {
            console.log('File sent successfully.');
            res.end();
        });

    } catch (error) {
        next(error);
    }
};

前端代码(React)

前端下载处理逻辑:

.then((response) => {
    const blob = new Blob([response.data], { type: response.headers['content-type'] });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filenames[index];
    link.click();

    setTimeout(() => {
        link.remove();
        URL.revokeObjectURL(url); // Release memory
    }, 500);
})
.catch((error) => {
    console.error(`Error downloading file ${filenames[index]}:`, error);
});

已完成的排查操作

  • 确认Content-Disposition头部设置正确(filename*=UTF-8''${encodedFilename});
  • 确保Content-Length已设置为文件实际大小;
  • 验证fs.createReadStream(filepath)可正确读取文件;
  • 尝试使用res.download(filepath)替代流式传输,问题仍存在;
  • iOS端Chrome/Safari测试正常,仅iOS Firefox出现异常。

问题咨询

  1. 为何仅iOS端Firefox会显示文件名「Unknown」?
  2. 为何下载后的文件为空或远小于预期大小?
  3. 是否存在可跨浏览器(尤其适配iOS Firefox)的兼容文件下载实现方案?

解决方案

问题1:文件名显示Unknown的原因与修复

iOS Firefox对filename*=UTF-8''格式的支持存在兼容性缺陷,建议同时添加传统filename字段作为降级方案,且保持文件名的URI编码状态(不要替换%20为空格):

// 后端修改Content-Disposition头部
const encodedFilename = encodeURIComponent(filename);
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`);

问题2:文件为空/大小异常的原因与修复

前端请求默认将响应解析为JSON(responseType默认值为json),会导致二进制文件被错误转成字符串,生成损坏的Blob。需强制设置responseType: 'blob',并直接使用响应返回的Blob对象:

// 前端请求时添加responseType配置
axios.post('/download', { filepath: '目标文件路径' }, { responseType: 'blob' })
.then((response) => {
    const url = URL.createObjectURL(response.data);
    const link = document.createElement('a');
    link.href = url;
    
    // 优先从响应头解析文件名,避免前端硬编码的不一致
    let filename = filenames[index];
    const contentDisposition = response.headers['content-disposition'];
    if (contentDisposition) {
        const matches = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
        if (matches?.[1]) {
            filename = decodeURIComponent(matches[1].replace(/['"]/g, ''));
        }
    }
    link.download = filename;
    link.click();

    setTimeout(() => {
        link.remove();
        URL.revokeObjectURL(url);
    }, 1000);
})

跨浏览器兼容的完整实现方案

后端优化

  • 同时设置filename和filename*字段,覆盖新旧浏览器兼容场景;
  • 根据文件扩展名设置实际MIME类型(如application/pdf、image/png),而非通用的application/octet-stream;
  • 移除res.end()调用,pipe会自动在流结束时终止响应,手动调用可能导致文件截断。

修改后的后端核心代码片段:

const filename = path.basename(filepath);
const encodedFilename = encodeURIComponent(filename);
// 双字段兼容设置
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`);
// 设置正确的MIME类型
const mime = require('mime-types');
const contentType = mime.lookup(filepath) || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', stat.size);

const fileStream = fs.createReadStream(filepath);
fileStream.pipe(res);

fileStream.on('end', () => {
    console.log('File sent successfully.');
    // 移除res.end(),由pipe自动处理
});

前端优化

  • 强制设置responseType: 'blob',确保二进制数据正确传输;
  • 从响应头Content-Disposition解析文件名,避免前端维护文件名列表的不一致;
  • 添加link.target = '_blank',适配iOS部分浏览器的下载触发逻辑;
  • 用事件监听替代固定延迟,确保资源在下载完成后再释放。

修改后的前端核心代码片段:

axios.post('/download', { filepath: targetPath }, { responseType: 'blob' })
.then((response) => {
    const url = URL.createObjectURL(response.data);
    const link = document.createElement('a');
    link.href = url;
    link.target = '_blank'; // 适配iOS浏览器

    // 从响应头解析文件名
    let filename = 'unknown-file';
    const contentDisposition = response.headers['content-disposition'];
    if (contentDisposition) {
        const filenameMatch = contentDisposition.match(/filename\*?=([^;]+)/);
        if (filenameMatch) {
            filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
        }
    }
    link.download = filename;

    // 确保下载完成后清理资源
    const cleanup = () => {
        link.remove();
        URL.revokeObjectURL(url);
    };
    link.addEventListener('click', cleanup);
    link.click();
    // 备用清理逻辑,防止事件未触发
    setTimeout(cleanup, 1000);
})
.catch((error) => {
    console.error('Download error:', error);
});

内容的提问来源于stack exchange,提问作者Chi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 00:55:12